| name | polar-billing |
|---|---|
| description | This skill should be used when working on Polar billing system, Stripe integration, subscription lifecycle, checkout flows, or benefit provisioning. |
| license | Apache-2.0 |
Comprehensive guide to Polar's billing infrastructure, covering entities, flows, Stripe integration, and benefit provisioning.
Checkout → Payment → Order → Transaction → Benefits
↓
Subscription (if recurring)
↓
Subscription Cycle → Order → ...
- Core Entities
- Entity Relationships
- Main Services
- Dramatiq Background Tasks
- Stripe Integration
- Subscription Lifecycle
- Proration System
- Benefits & Credits
- Dunning & Payment Retry
- Transaction Ledger
- Key File Locations
File: server/polar/models/checkout.py
Shopping cart/payment session before order confirmation.
| Field | Type | Description |
|---|---|---|
status |
CheckoutStatus | open, expired, confirmed, succeeded, failed |
payment_processor |
PaymentProcessor | stripe, manual |
client_secret |
str | Unique identifier for frontend |
amount, currency |
int, str | Price in cents |
tax_amount, discount_amount |
int | Calculated amounts |
allow_trial, trial_end |
bool, datetime | Trial configuration |
seats |
int | For seat-based products |
Relationships: organization, customer, product, product_price, discount, subscription (for upgrades)
File: server/polar/models/checkout_link.py
Persistent URL that creates Checkout Sessions on visit.
| Field | Type | Description |
|---|---|---|
client_secret |
str | Unique identifier for the URL |
seats |
int | None | Preconfigured seat count for seat-based pricing |
discount_id |
UUID | None | Preset discount to apply |
trial_interval, trial_interval_count |
Trial config | Override product trial settings |
Relationships: organization, products, discount
File: server/polar/models/order.py
Represents a billing event (one-time purchase or subscription cycle).
| Field | Type | Description |
|---|---|---|
status |
OrderStatus | pending, paid, refunded, partially_refunded |
billing_reason |
OrderBillingReason | purchase, subscription_create, subscription_cycle, subscription_update |
subtotal_amount |
int | Amount before discount/tax |
discount_amount |
int | Discount applied |
tax_amount |
int | Tax collected |
applied_balance_amount |
int | Account balance applied |
platform_fee_amount |
int | Polar's fee |
refunded_amount |
int | Already refunded |
next_payment_attempt_at |
datetime | Dunning retry time |
Computed Properties:
net_amount= subtotal - discounttotal_amount= net + taxdue_amount= max(0, total + applied_balance)payout_amount= net - platform_fee - refunded
File: server/polar/models/subscription.py
Recurring billing relationship.
| Field | Type | Description |
|---|---|---|
status |
SubscriptionStatus | incomplete, trialing, active, past_due, canceled, unpaid |
amount, currency |
int, str | Subscription gross price |
net_amount |
int | Net amount (gross minus inclusive tax, equal to gross if tax-exclusive) |
tax_behavior |
TaxBehavior | None | Inclusive, exclusive, or null (set at creation) |
recurring_interval |
Interval | month, year |
current_period_start/end |
datetime | Billing period |
trial_start/end |
datetime | Trial period |
cancel_at_period_end |
bool | Scheduled cancellation |
canceled_at, ended_at |
datetime | Lifecycle timestamps |
past_due_at |
datetime | When payment failed |
seats |
int | For seat-based pricing |
Relationships: organization, customer, product, payment_method, discount, meters, grants (benefits)
File: server/polar/models/transaction.py
All money flows in the system.
| Field | Type | Description |
|---|---|---|
type |
TransactionType | payment, processor_fee, refund, dispute, balance, payout |
processor |
Processor | stripe, manual |
amount, currency |
int, str | Transaction amount |
tax_amount |
int | Tax portion |
Self-referential relationships: payment_transaction, balance_transactions, incurred_transactions
File: server/polar/models/payment.py
Individual payment transaction.
| Field | Type | Description |
|---|---|---|
status |
PaymentStatus | pending, succeeded, failed |
processor_id |
str | Stripe charge ID |
method |
str | card, bank_transfer, etc. |
trigger |
PaymentTrigger | None | What initiated payment: purchase, subscription_cycle, retry_dunning, retry_customer, retry_payment_method_update, retry_admin |
decline_reason |
str | Why payment failed |
risk_level, risk_score |
str, int | Fraud assessment |
File: server/polar/models/refund.py
| Field | Type | Description |
|---|---|---|
status |
RefundStatus | pending, succeeded, failed, canceled |
reason |
RefundReason | duplicate, fraudulent, customer_request, etc. |
amount, tax_amount |
int | Refund amounts |
revoke_benefits |
bool | Whether to revoke customer benefits |
File: server/polar/models/customer.py
| Field | Type | Description |
|---|---|---|
email, name |
str | Contact info |
billing_name |
str | Name for invoices (falls back to name) |
stripe_customer_id |
str | Stripe link |
billing_address |
Address | Stored address |
tax_id |
str | For tax compliance |
Files: server/polar/models/product.py, server/polar/models/product_price.py
| ProductPrice Types | Description |
|---|---|
ProductPriceFixed |
Fixed amount (set price_amount=0 for free) |
ProductPriceCustom |
Merchant sets at checkout |
ProductPriceMeteredUnit |
Pay-per-unit |
ProductPriceSeatUnit |
Per-seat with tiers |
File: server/polar/models/billing_entry.py
Audit log for billing calculations.
| Field | Type | Description |
|---|---|---|
type |
BillingEntryType | cycle, proration, metered, seats_increase, seats_decrease |
direction |
Direction | debit, credit |
amount |
int | Entry amount |
Organization
├── Product
│ ├── ProductPrice (multiple per product)
│ └── ProductBenefit → Benefit
├── Customer
│ ├── Subscription → Organization, Product, Discount
│ │ ├── SubscriptionProductPrice
│ │ ├── SubscriptionMeter
│ │ └── BenefitGrant
│ ├── Order → Product, Subscription
│ │ └── OrderItem
│ ├── PaymentMethod
│ └── Wallet
├── Checkout → Customer, Product
├── Discount
│ └── DiscountRedemption
└── Account (for payouts)
└── Payout → Transaction
Transaction (ledger)
├── payment → Order, Customer
├── refund → Refund, Order
├── dispute → Dispute, Order
├── processor_fee → parent payment
└── payout → Account
File: server/polar/subscription/service.py
Core subscription operations:
# Creation
create_or_update_from_checkout(checkout, payment_method) → (Subscription, created)
# Updates
update_product(subscription, product_id, proration_behavior, discount=None)
update_seats(subscription, seats, proration_behavior)
update_discount(subscription, discount) # discount: UUID | Literal["unset"]
update_trial(subscription, trial_end)
# Lifecycle
cycle(subscription) # Period renewal
cancel(subscription) # At period end
revoke(subscription) # Immediately
uncancel(subscription)
# Benefits
enqueue_benefits_grants(task="grant"|"revoke", customer, product)File: server/polar/order/service.py
create_from_checkout(checkout) # One-time purchase
create_subscription_order(subscription, billing_reason) # Recurring
trigger_payment(order) # Charge customer
create_order_balance(order) # Ledger entriesFile: server/polar/checkout/service.py
create(product, customer_data, discount_code)
confirm(checkout) # Lock checkout for payment
handle_stripe_success(checkout, charge)
handle_free_success(checkout) # No payment neededFile: server/polar/payment/service.py
upsert_from_stripe_charge(charge, checkout, order)
handle_success(payment) # Complete order
handle_failure(payment) # Update order statusFile: server/polar/refund/service.py
create(order, amount, reason, revoke_benefits)
upsert_from_stripe(stripe_refund)
# Also enqueues chargeback prevention notice for dispute_prevention refundsFile: server/polar/benefit/grant/service.py
enqueue_benefits_grants(task, customer, product, order=None, subscription=None)
grant_benefit(customer, benefit)
revoke_benefit(customer, benefit)File: server/polar/subscription/tasks.py
| Task | Trigger | Action |
|---|---|---|
subscription.cycle |
Scheduler at period end | Renew subscription, create order |
subscription.update_product_benefits_grants |
Product benefits changed | Update all grants |
subscription.cancel_customer |
Customer deleted | Cancel all billable subscriptions (trialing, active, past_due) |
File: server/polar/order/tasks.py
| Task | Trigger | Action |
|---|---|---|
order.create_subscription_order |
Subscription cycle | Create billing order |
order.trigger_payment |
Order ready | Charge payment method |
order.balance |
Payment success | Create ledger entries |
order.invoice |
Order created | Generate PDF invoice |
order.process_dunning |
Hourly cron | Find orders for retry |
order.process_dunning_order |
Individual retry | Retry single payment |
File: server/polar/integrations/stripe/tasks.py
| Task | Stripe Event | Action |
|---|---|---|
charge.succeeded |
Payment complete | Create order, provision benefits |
charge.failed |
Payment failed | Mark order failed |
charge.updated |
Charge settled | Create ledger transaction |
refund.created/updated |
Refund processed | Update refund record |
charge.dispute.created |
Chargeback | Create dispute, revoke benefits |
payout.paid |
Payout complete | Update payout status |
File: server/polar/benefit/tasks.py
| Task | Trigger | Action |
|---|---|---|
benefit.enqueue_benefits_grants |
Order/subscription | Queue individual grants |
benefit.grant |
Individual benefit | Provision access (GitHub, Discord, etc.) |
benefit.revoke |
Cancellation/refund | Remove access |
benefit.cycle |
Subscription renewal | Reset credits with rollover |
File: server/polar/checkout/tasks.py
| Task | Trigger | Action |
|---|---|---|
checkout.handle_free_success |
Free product | Complete without payment |
checkout.expire_open_checkouts |
Every 15 min | Mark expired checkouts |
File: server/polar/payout/tasks.py
| Task | Trigger | Action |
|---|---|---|
payout.trigger_stripe_payouts |
Daily 00:15 UTC | Initiate pending payouts |
payout.created |
Payout created | Event hook (fires for held payouts too) |
payout.transfer |
After payout.created | Stripe transfer (skipped for held) |
payout.release_held_payouts |
Org approved | Move held → pending, enqueue transfers |
payout.cancel_account_payouts |
Org denied/blocked/offboarding | Cancel held+pending payouts |
payout.cancel_held_payouts |
Payout account swap | Cancel only held payouts on old account |
File: server/polar/refund/tasks.py
| Task | Trigger | Action |
|---|---|---|
refund.send_chargeback_prevention_notice |
Dispute prevention refund created | Email org owners/admins about refund |
File: server/polar/integrations/stripe/endpoints.py
/v1/integrations/stripe/webhook- Direct webhooks/v1/integrations/stripe/webhook-connect- Connect account webhooks
Payment Flow:
payment_intent.succeeded- Payment completepayment_intent.payment_failed- Payment failedsetup_intent.succeeded- Card savedcharge.pending/failed/succeeded/updated- Charge lifecycle
Refunds:
refund.created/updated/failed
Disputes:
charge.dispute.created/updated/closed
Connect:
account.updated- Account info changedpayout.updated/paid- Payout lifecycle
Stripe POST → Verify signature → ExternalEvent.enqueue()
↓
Store in external_events table
↓
Enqueue Dramatiq task
↓
Worker processes async
↓
Mark handled_at on success
File: server/polar/integrations/stripe/service.py
Key methods:
create_payment_intent(),create_setup_intent()create_refund(),get_refund()create_tax_calculation(),create_tax_transaction()transfer(),create_payout()
1. Checkout created (status=open)
2. Customer completes payment
3. Stripe charge.succeeded webhook
4. payment.handle_success() called
5. checkout_service.handle_stripe_success()
6. subscription_service.create_or_update_from_checkout()
- Creates Subscription (status=active or trialing)
- Sets billing period
- Applies discount
- Resets meters
7. Enqueue benefit grants
8. Send confirmation email
1. APScheduler triggers at period end
2. subscription.cycle task runs
3. subscription_service.cycle()
- Check cancel_at_period_end
- If true: set status=canceled, revoke benefits
- If false: advance period dates, check discount expiry
4. Create billing entry (type=cycle)
5. Enqueue order.create_subscription_order
6. Order created with billing_reason=subscription_cycle
7. Enqueue order.trigger_payment
8. Stripe charges payment method
9. charge.succeeded → ledger entries → benefits renewed
At Period End:
subscription_service.cancel(subscription)
# Sets cancel_at_period_end=True, ends_at=current_period_end
# Benefits remain until period ends
# On next cycle: status=canceled, benefits revokedImmediately:
subscription_service.revoke(subscription)
# Sets status=canceled, ended_at=now
# Benefits revoked immediately
# Seats canceled if seat-based1. Checkout with trial_end set
2. Subscription created with status=trialing
3. No payment during trial
4. At trial_end, cycle task runs
5. Status transitions to active
6. Order created with billing_reason=subscription_cycle_after_trial
7. First payment charged
- Product change - Upgrade/downgrade to different tier
- Seat change - Add/remove seats
- Interval change - Monthly to yearly
# Calculate time remaining in period
pct_remaining = (period_end - now) / (period_end - period_start)
# Old product credit (what they paid but won't use)
old_credit = old_price * old_pct_remaining
# New product debit (what they owe for remainder)
new_debit = new_price * new_pct_remaining
# Net proration
net = new_debit - old_credit| Behavior | Action |
|---|---|
prorate |
Add to next invoice |
invoice |
Create order immediately |
# Credit entry (old product)
BillingEntry(
type=BillingEntryType.proration,
direction=BillingEntryDirection.credit,
amount=prorated_old_amount
)
# Debit entry (new product)
BillingEntry(
type=BillingEntryType.proration,
direction=BillingEntryDirection.debit,
amount=prorated_new_amount
)# Adding 2 seats at $10/seat with 50% time remaining
delta_amount = 2 * $10 * 0.5 = $10
BillingEntry(
type=BillingEntryType.subscription_seats_increase,
direction=BillingEntryDirection.debit,
amount=1000 # cents
)| Type | Description | Grant Action |
|---|---|---|
meter_credit |
Usage allowances | Create meter_credited event |
github_repository |
Repo access | Add to GitHub team |
discord |
Server role | Assign Discord role |
license_keys |
License distribution | Generate key |
downloadables |
File access | Grant download permission |
slack_shared_channel |
Slack Connect channel | Create/invite to shared channel |
feature_flag |
Feature toggle (API-only) | None — merchant reads via API |
custom |
Customer-visible note | None — displayed in customer portal |
1. Order/Subscription created
2. enqueue_benefits_grants(task="grant")
3. For each benefit in product:
- Skip if already granted
- Enqueue benefit.grant task
4. benefit.grant task:
- Get/create BenefitGrant record
- Call strategy.grant() (type-specific)
- Set granted_at
- Store properties
- Send webhook
1. Subscription canceled or order refunded
2. enqueue_benefits_grants(task="revoke")
3. For each granted benefit:
- Enqueue benefit.revoke task
4. benefit.revoke task:
- Call strategy.revoke() (type-specific)
- Set revoked_at
- Send webhook
Grant:
# Create event with units
Event(type="meter_credited", units=100)
# Update CustomerMeterCycle (renewal):
# Calculate rollover
rollover = min(remaining_units, rollover_limit)
# Reset meter
Event(type="meter_reset")
# Credit new period + rollover
Event(type="meter_credited", units=base_units + rollover)Revoke:
# Negative credit event
Event(type="meter_credited", units=-remaining_units)Organizations can configure benefit_revocation_grace_period (days) to delay benefit revocation for past_due subscriptions.
1. order.process_dunning runs hourly
2. Finds orders where next_payment_attempt_at <= now
3. For each order:
- Enqueue order.process_dunning_order
4. process_dunning_order:
- Get customer's payment method
- Attempt payment via Stripe
- On success: mark order paid
- On failure: schedule next attempt
Configured in organization settings. Typical pattern:
- Day 1: First failure
- Day 3: Retry 1
- Day 5: Retry 2
- Day 7: Final retry, then mark unpaid
payment fails → status=past_due, past_due_at=now
↓
benefits may continue (grace period)
↓
retry succeeds → status=active
↓
retry fails → status=unpaid, benefits revoked
| Type | Description |
|---|---|
payment |
Customer payment received |
processor_fee |
Stripe fees |
refund |
Money returned to customer |
refund_reversal |
Refund failed/reversed |
dispute |
Chargeback loss |
dispute_reversal |
Won dispute |
balance |
Internal balance transfer |
payout |
Money sent to creator |
1. charge.updated webhook (charge settled)
2. Get balance_transaction from Stripe
3. Extract settlement amount and fees
4. Create Transaction(type=payment)
5. Enqueue processor_fee.create_payment_fees
6. Create Transaction(type=processor_fee)
1. Creator has balance from transactions
2. payout.trigger_stripe_payouts (daily)
3. Calculate available balance
4. Create Payout record
- ACTIVE/OFFBOARDED org: status=pending, enqueue payout.created + payout.transfer
- REVIEW/SNOOZED org: status=held, enqueue payout.created only
5. stripe_service.transfer() to Connect account (skipped for held)
6. stripe_service.create_payout() to bank
7. payout.paid webhook → update status
Held payout lifecycle:
- When org approved: payout.release_held_payouts → status=pending, enqueue transfer
- When org denied/blocked/offboarding: payout.cancel_account_payouts → cancel + refund
- When payout account swapped: payout.cancel_held_payouts (old account only)
server/polar/models/
├── checkout.py
├── order.py
├── order_item.py
├── subscription.py
├── subscription_product_price.py
├── transaction.py
├── payment.py
├── refund.py
├── dispute.py
├── payout.py
├── customer.py
├── product.py
├── product_price.py
├── discount.py
├── benefit.py
├── benefit_grant.py
└── billing_entry.py
server/polar/
├── subscription/service.py
├── order/service.py
├── checkout/service.py
├── payment/service.py
├── refund/service.py
├── dispute/service.py
├── payout/service.py
├── benefit/
│ ├── service.py
│ ├── grant/service.py
│ └── strategies/
│ ├── meter_credit/service.py
│ ├── github_repository/service.py
│ ├── discord/service.py
│ └── ...
└── transaction/service/
├── payment.py
├── refund.py
└── dispute.py
server/polar/
├── subscription/tasks.py
├── order/tasks.py
├── checkout/tasks.py
├── benefit/tasks.py
├── payout/tasks.py
├── refund/tasks.py
└── integrations/stripe/tasks.py
server/polar/integrations/stripe/
├── endpoints.py # Webhook handlers
├── service.py # Stripe API wrapper
├── tasks.py # Webhook processing tasks
└── payment.py # Payment resolution helpers
- Check
Paymentrecord fordecline_reason - Check
Order.statusandnext_payment_attempt_at - Look at external_events for Stripe webhook
- Check
BenefitGrantrecord for errors - Look at benefit.grant task in Dramatiq logs
- Verify product has benefits attached
- Check
BillingEntryrecords for subscription - Verify billing_reason on Order
- Check subscription's current_period dates
- Check
scheduler_locked_aton subscription - Verify APScheduler is running
- Check subscription.cycle task logs