Reusable Django billing package for Stripe and Plisio payments
Project description
django-stripe-plisio
Reusable Django package for billing: plan catalog, invoices, balance, promo codes, and two payment providers — Stripe (cards, subscriptions) and Plisio (crypto invoices).
The package is not tied to a specific project: it is integrated via INSTALLED_APPS, settings constants, and include() URLs. The link to the user is only through settings.AUTH_USER_MODEL.
Table of Contents
- Business process: from setup to payment
- Features
- Architecture
- Package structure
- Installation
- Integration in a project
- Configuration
- Scheduled tasks (cron)
- Data models
- Service layer
- Payment providers
- REST API
- Webhooks
- Signals
- Django Admin
- Common scenarios
- Invariants and constraints
- Development and testing
Business process: from setup to payment
Below is the full path of how this works in practice. First, the developer integrates the package and configures keys in settings; then, the manager fills the catalog in the admin; finally, the user pays through your code.
flowchart LR
A[1. Settings and integration] --> B[2. Admin: products and prices]
B --> C[3. Code: invoice and redirect]
C --> D[4. User pays]
D --> E[5. Webhook confirms]
E --> F[6. Admin: paid, ledger]
E -.-> G[5b. Cron sync — if webhook is lost]
Step 1. Settings and package integration (once, first)
Until variables are configured and the package is integrated, payments will not work. This is done by the developer.
1.1. Installation and apps in the project
pip install django-stripe-plisio[api]
# settings.py
INSTALLED_APPS = [
# ...
"django_stripe_plisio",
"django_stripe_plisio.billing",
"django_stripe_plisio.payments",
]
# urls.py
urlpatterns = [
path("admin/", admin.site.urls),
path("billing/", include("django_stripe_plisio.urls")),
]
python manage.py migrate dsp_billing
python manage.py migrate dsp_payments
After migrations, Billing and Payments sections appear in /admin/.
1.2. Stripe and Plisio keys
Obtain them from the Stripe Dashboard and Plisio Dashboard, then set them in settings.py or .env:
# Stripe — cards and subscriptions
DJANGO_STRIPE_PLISIO_STRIPE_SECRET_KEY = "sk_live_..." # test: sk_test_...
DJANGO_STRIPE_PLISIO_STRIPE_WEBHOOK_SECRET = "whsec_..." # Stripe → Developers → Webhooks
# Plisio — crypto invoices
DJANGO_STRIPE_PLISIO_PLISIO_API_KEY = "your_api_key"
DJANGO_STRIPE_PLISIO_PLISIO_CALLBACK_SECRET = "secret_from_dashboard"
DJANGO_STRIPE_PLISIO_PLISIO_WEBHOOK_URL = "https://your-site.com/billing/webhooks/plisio/"
# Currencies and redirects after payment
DJANGO_STRIPE_PLISIO_DEFAULT_CURRENCY = "USD"
DJANGO_STRIPE_PLISIO_ALLOWED_CURRENCIES = ["USD", "EUR", "RUB"]
DJANGO_STRIPE_PLISIO_SUCCESS_URL = "https://your-site.com/payment/success"
DJANGO_STRIPE_PLISIO_CANCEL_URL = "https://your-site.com/payment/cancel"
# Security and invoice lifetime
DJANGO_STRIPE_PLISIO_REQUIRE_WEBHOOK_SECRET = True # always True in prod
DJANGO_STRIPE_PLISIO_INVOICE_PENDING_TTL_HOURS = 24 # optional
DJANGO_STRIPE_PLISIO_USER_ID_FIELD = "pk" # or your User's uuid field
# Scheduled tasks: status catch-up and pending expiration (optional, see «Scheduled tasks» section)
DJANGO_STRIPE_PLISIO_CRON = {
"sync_invoices": {
"enabled": True,
"schedule": "*/10 * * * *",
},
"expire_invoices": {
"enabled": True,
"schedule": "0 * * * *",
},
}
DJANGO_STRIPE_PLISIO_INVOICE_SYNC_BATCH_SIZE = 100
| Variable | Purpose |
|---|---|
STRIPE_SECRET_KEY |
Creating Checkout Session and polling status in cron |
STRIPE_WEBHOOK_SECRET |
Stripe signature verification (required in prod) |
PLISIO_API_KEY |
Creating crypto invoice and polling operation in cron |
PLISIO_CALLBACK_SECRET |
Plisio verify_hash verification |
PLISIO_WEBHOOK_URL |
Callback URL for Plisio API (callback_url) |
SUCCESS_URL / CANCEL_URL |
User redirect after Stripe Checkout |
REQUIRE_WEBHOOK_SECRET |
Reject webhooks without a secret (fail-closed) |
ALLOWED_CURRENCIES |
Allowed currencies in invoices |
INVOICE_PENDING_TTL_HOURS |
expires_at for pending + dsp_expire_invoices |
CRON |
Schedule for dsp_sync_invoices / dsp_expire_invoices |
INVOICE_SYNC_BATCH_SIZE |
How many pending invoices to poll per sync run |
Important:
PLISIO_WEBHOOK_URLis the application endpoint (/billing/webhooks/plisio/), not the «thank you for your payment» page.
1.3. Webhooks at providers
The primary channel for payment confirmation is the webhook. Without it, the invoice stays pending even if the user has already paid.
| Provider | URL in provider dashboard | Event |
|---|---|---|
| Stripe | https://your-site.com/billing/webhooks/stripe/ |
checkout.session.completed |
| Plisio | https://your-site.com/billing/webhooks/plisio/ |
callback on completed status |
1.4. Cron (fallback channel)
If a webhook did not arrive (network, downtime, wrong URL), enable periodic sync — the dsp_sync_invoices command polls the Stripe/Plisio API by external_id of pending invoices. Details — Scheduled tasks (cron).
Minimum for prod:
- Configure webhooks (step 1.3).
- Enable
DJANGO_STRIPE_PLISIO_CRONand register tasks (django-crontabor system cron). - First schedule
dsp_sync_invoices, thendsp_expire_invoices.
At this step the integration is ready — you can move on to the catalog.
Step 2. Manager fills the catalog in the admin
Once settings are configured, go to /admin/ and create what you sell.
| Admin section | What to fill in | Why |
|---|---|---|
| Billing → Products | code (e.g. pro), name, description, is_active |
Plan or service in the catalog |
| Billing → Prices | product, currency (USD/EUR/…), amount_minor, period |
Price: 999 = 9.99 USD (in cents), not 9.99 in the field |
| Billing → Promo codes (optional) | code SAVE10, percent or fixed |
Public discount |
| Billing → Discount grants (optional) | user, discount type | Personal discount without a promo code |
Example: Pro plan = 1999 amount_minor + USD → the user pays $19.99.
For Stripe subscriptions — period month or year. For one-time payment — one_time.
Step 3. Write code: user selects a plan and goes to payment
The user in your application (website, API, dashboard) clicks «Buy». In code:
from django_stripe_plisio import api
from django_stripe_plisio.billing.models import Price
def buy_pro_plan(request):
# 1) Get the price from the catalog (the one created in the admin)
price = Price.objects.get(product__code="pro", currency="USD", is_active=True)
# 2) Issue an invoice to the user
# provider="stripe" — card payment
# provider="plisio" — crypto payment
invoice = api.create_invoice(
user=request.user,
price=price,
provider="stripe",
quantity=1,
promo_code=request.POST.get("promo"), # or None
)
# 3) Create a payment session at the provider
attempt = api.create_checkout(invoice)
# 4) Send the user to the payment page
return redirect(attempt.payment_url)
What happens internally:
- An Invoice (status
pending) and an InvoiceLine row with a price snapshot appear in the database. - If the promo code is valid —
total_minoris recalculated, an InvoiceDiscount is created. - A PaymentAttempt and
payment_urllink are created (Stripe Checkout or Plisio invoice).
The same scenario via REST API (if the frontend is Vue/React):
POST /billing/invoices/
{ "price_id": 1, "quantity": 1, "provider": "stripe", "promo_code": "SAVE10" }
POST /billing/payments/stripe/checkout/
{ "invoice_id": 42 }
→ payment_url in the response — redirect the user
Step 4. User pays
| Channel | Where they pay | What they see |
|---|---|---|
| Stripe | Stripe Checkout page | Card form; for subscriptions — monthly charge |
| Plisio | Plisio page | Address / QR for cryptocurrency transfer |
While the user is paying, the invoice in the admin: Billing → Invoices → status pending.
Step 5. After payment — what happens automatically
- Stripe or Plisio sends a webhook to your server.
- The package verifies the signature, saves WebhookEvent (no duplicates on retry).
- The invoice is moved to paid, a credit is written to BalanceLedger.
- UserEntitlement is created (access to the plan).
- Signals fire — in your code you can send an email or enable a feature:
from django.dispatch import receiver
from django_stripe_plisio.signals import invoice_paid
@receiver(invoice_paid)
def send_receipt(sender, invoice, **kwargs):
# your email / notification
...
Step 6. Where to check the result in the admin
After successful payment, the manager or support checks:
| Admin menu | What to look at |
|---|---|
| Billing → Invoices | Status paid, amount, paid_at, link to provider |
| Billing → Balance ledger | credit entry for the invoice amount |
| Billing → User entitlements | Granted product access and period |
| Payments → Payment attempts | Attempt with succeeded status, payment_url, errors if any |
| Payments → Stripe payment attempts | Stripe payments only |
| Payments → Plisio payment attempts | Crypto payments only |
| Payments → Webhook events | Raw callbacks, processing status |
| Payments → Provider transactions | Reconciliation with provider transaction ID |
Quick check «did the client pay»:
Billing → Invoices → filter by user and status paid.
Troubleshooting «money was charged, no access»:
Payments → Webhook events — is there an event with processed status; if failed — check error_message.
Step 7. Two payment channels — how to choose
| Need | Provider in create_invoice |
Admin attempts |
|---|---|---|
| Card, Apple Pay, subscription | "stripe" |
Stripe payment attempts |
| Cryptocurrency | "plisio" |
Plisio payment attempts |
The same product can have multiple Prices (USD/EUR) — the user selects the currency on the frontend, you pass the appropriate price_id.
Quick role cheat sheet
| Role | Actions | When |
|---|---|---|
| Developer | Step 1: pip, INSTALLED_APPS, migrate, all DJANGO_STRIPE_PLISIO_*, webhooks, cron |
First |
| Manager | Step 2: Products, Prices, Promo codes in the admin | After settings are configured |
| Developer | Step 3: create_invoice + create_checkout code, signals |
After the catalog |
| User | «Buy» → Stripe/Plisio → return to SUCCESS_URL |
In production |
| Support | Step 6: Invoices (paid?), Payment attempts, Webhook events | After payment |
The rest of the document covers technical details: models, API, architecture.
Features
| Area | What the package does |
|---|---|
| Catalog | Products (Product) and prices (Price) in minor units + currency |
| Invoices | Invoice + InvoiceLine rows with a price snapshot at issue time |
| Discounts | Public PromoCode and private DiscountGrant |
| Payments | Stripe Checkout / Plisio invoice, separate attempts and logs |
| Balance | Append-only BalanceLedger, balance calculation per currency |
| Access | UserEntitlement after payment (no rigid business logic in the package) |
| Subscriptions | StripeSubscription — recurring only through Stripe |
| Audit | PaymentAttempt, WebhookEvent, ProviderTransaction |
| Cron | dsp_sync_invoices — poll provider APIs; dsp_expire_invoices — TTL pending |
| Scheduling | DJANGO_STRIPE_PLISIO_CRON + build_cronjobs() for django-crontab |
Architecture
flowchart TD
subgraph catalog [Catalog]
Product --> Price
end
subgraph billing [Billing]
Price --> Invoice
Invoice --> InvoiceLine
PromoCode --> InvoiceDiscount
DiscountGrant --> InvoiceDiscount
Invoice --> InvoiceDiscount
Invoice --> BalanceLedger
Invoice --> UserEntitlement
end
subgraph payments [Payments]
Invoice --> PaymentAttempt
PaymentAttempt --> WebhookEvent
WebhookEvent --> ProviderTransaction
Price --> StripeSubscription
end
User[(AUTH_USER_MODEL)] --> Invoice
User --> DiscountGrant
User --> BalanceLedger
User --> UserEntitlement
User --> StripeSubscription
Payment flow (simplified):
- An
Invoiceis created (with optional discount). create_checkout()creates aPaymentAttemptand a session at the provider (Stripe / Plisio).- The user pays on the provider side.
- Webhook arrives at
/billing/webhooks/...→ idempotent processing →mark_invoice_paid(). - (optional) Cron
dsp_sync_invoicescatches up payment if the webhook was lost. BalanceLedgeris recorded,UserEntitlementif needed,invoice_paidsignal is sent.- (optional) Cron
dsp_expire_invoicesmoves overdue pending toexpiredbyexpires_at.
Package structure
src/django_stripe_plisio/
├── __init__.py # package version
├── apps.py # root AppConfig, signals connected on ready()
├── conf.py # reading DJANGO_STRIPE_PLISIO_* from settings
├── cron.py # build_cronjobs() for django-crontab
├── signals.py # invoice_paid, payment_failed, balance_changed, entitlement_granted
├── api.py # public Python API (re-export of services)
├── urls.py # root URLs: API (if DRF installed) + webhooks
│
├── billing/ # billing domain (app label: dsp_billing)
│ ├── models.py # Product, Price, Invoice, discounts, ledger, entitlements
│ ├── enums.py # statuses, providers, discount and ledger types
│ ├── services.py # create_invoice, discounts, ledger, mark_invoice_paid, expire_pending_invoices
│ ├── management/commands/
│ │ ├── dsp_expire_invoices.py
│ │ └── dsp_sync_invoices.py
│ ├── admin.py # billing admin
│ └── migrations/
│
├── payments/ # payments and webhooks (app label: dsp_payments)
│ ├── models.py # PaymentAttempt, WebhookEvent, ProviderTransaction, StripeSubscription
│ ├── enums.py # attempt, webhook, subscription statuses
│ ├── services/
│ │ ├── base.py # abstract BasePaymentProvider
│ │ ├── stripe_service.py
│ │ ├── plisio_service.py
│ │ └── invoice_sync.py # sync_pending_invoices()
│ ├── sync_types.py # InvoiceSyncOutcome, SyncResult
│ ├── views/webhooks.py
│ ├── urls_webhooks.py
│ ├── admin.py
│ └── migrations/
│
└── api/ # REST API (requires extra [api])
├── serializers.py
├── views.py
└── urls.py
demo_project/ # integration example and pytest environment
tests/ # package tests
Key module responsibilities
| File | Responsibility |
|---|---|
conf.py |
Single access point for settings (PackageSettings): API keys, currencies, cron, sync |
cron.py |
build_cronjobs() — building CRONJOBS for django-crontab |
billing/services.py |
Invoice business logic, discount calculation, ledger, moving invoice to paid |
payments/services/ |
Stripe and Plisio integration: checkout, webhook verify, event handling |
payments/views/webhooks.py |
HTTP endpoints without CSRF for provider callbacks |
api.py |
Stable public API for consumer code imports |
api/* |
DRF views/serializers; not loaded if DRF is not installed |
Installation
# Minimum: models, admin, services, webhooks
pip install django-stripe-plisio
# With REST API
pip install django-stripe-plisio[api]
# With django-crontab (schedule from DJANGO_STRIPE_PLISIO_CRON)
pip install django-stripe-plisio[cron]
# API + cron
pip install django-stripe-plisio[api,cron]
# For development
pip install -e ".[dev]"
Dependencies:
Django>=5.0stripe— Stripe APIrequests— Plisio HTTP APIdjangorestframework— only with extra[api]django-crontab— only with extra[cron]
Integration in a project
1. INSTALLED_APPS
INSTALLED_APPS = [
# ...
"django_stripe_plisio",
"django_stripe_plisio.billing",
"django_stripe_plisio.payments",
]
2. Migrations
python manage.py migrate dsp_billing
python manage.py migrate dsp_payments
3. URL
from django.urls import include, path
urlpatterns = [
path("billing/", include("django_stripe_plisio.urls")),
]
After integration:
| Path | Purpose |
|---|---|
/billing/products/ |
Product list (DRF) |
/billing/prices/ |
Price list |
/billing/invoices/ |
Invoice list / creation |
/billing/invoices/<id>/ |
Invoice details |
/billing/payments/stripe/checkout/ |
Stripe Checkout Session |
/billing/payments/plisio/invoice/ |
Plisio crypto invoice |
/billing/balance/ledger/ |
User balance ledger entries |
/billing/webhooks/stripe/ |
Stripe webhook |
/billing/webhooks/plisio/ |
Plisio callback |
REST routes are available only when
djangorestframeworkis installed.
4. User
The package does not create its own User model. It uses settings.AUTH_USER_MODEL from your project.
Configuration
All constants are read with the DJANGO_STRIPE_PLISIO_ prefix (see conf.py → PackageSettings).
| Constant | Required | Description |
|---|---|---|
DJANGO_STRIPE_PLISIO_STRIPE_SECRET_KEY |
for Stripe | Secret key (sk_...) |
DJANGO_STRIPE_PLISIO_STRIPE_WEBHOOK_SECRET |
for Stripe webhooks | Signing secret (whsec_...) |
DJANGO_STRIPE_PLISIO_PLISIO_API_KEY |
for Plisio | Plisio API key |
DJANGO_STRIPE_PLISIO_PLISIO_CALLBACK_SECRET |
for Plisio | Secret for verify_hash check in callback |
DJANGO_STRIPE_PLISIO_DEFAULT_CURRENCY |
recommended | Default currency, e.g. "USD" |
DJANGO_STRIPE_PLISIO_ALLOWED_CURRENCIES |
recommended | List of allowed currencies, e.g. ["USD", "EUR", "RUB"] |
DJANGO_STRIPE_PLISIO_SUCCESS_URL |
for checkout | URL after successful payment |
DJANGO_STRIPE_PLISIO_CANCEL_URL |
for checkout | URL on cancellation |
DJANGO_STRIPE_PLISIO_USER_ID_FIELD |
optional | User field for metadata (default "pk") |
DJANGO_STRIPE_PLISIO_INVOICE_PENDING_TTL_HOURS |
optional | Pending invoice TTL → expires_at |
DJANGO_STRIPE_PLISIO_CRON |
for cron | Schedule for sync_invoices / expire_invoices tasks |
DJANGO_STRIPE_PLISIO_INVOICE_SYNC_BATCH_SIZE |
optional | Invoice limit per sync run (default 100) |
DJANGO_STRIPE_PLISIO_INVOICE_SYNC_PROVIDERS |
optional | ["stripe", "plisio"] or all if not set |
Example:
DJANGO_STRIPE_PLISIO_STRIPE_SECRET_KEY = env("STRIPE_SECRET_KEY")
DJANGO_STRIPE_PLISIO_STRIPE_WEBHOOK_SECRET = env("STRIPE_WEBHOOK_SECRET")
DJANGO_STRIPE_PLISIO_PLISIO_API_KEY = env("PLISIO_API_KEY")
DJANGO_STRIPE_PLISIO_PLISIO_CALLBACK_SECRET = env("PLISIO_CALLBACK_SECRET")
DJANGO_STRIPE_PLISIO_DEFAULT_CURRENCY = "USD"
DJANGO_STRIPE_PLISIO_ALLOWED_CURRENCIES = ["USD", "EUR", "RUB"]
DJANGO_STRIPE_PLISIO_SUCCESS_URL = "https://example.com/billing/success"
DJANGO_STRIPE_PLISIO_CANCEL_URL = "https://example.com/billing/cancel"
Scheduled tasks (cron)
Webhooks are the primary way to learn about payment. Cron is a fallback: polling the provider API and local expiration of pending by TTL.
flowchart TD
subgraph cronFlow [Recommended cron order]
S[dsp_sync_invoices] --> E[dsp_expire_invoices]
end
S --> StripeAPI[Stripe Session.retrieve]
S --> PlisioAPI[Plisio GET /operations/id]
StripeAPI --> Paid[mark_invoice_paid]
PlisioAPI --> Paid
E --> Expired[Invoice status expired]
Management commands
| Command | Purpose |
|---|---|
python manage.py dsp_sync_invoices |
API polling: pending invoices with non-empty external_id |
python manage.py dsp_expire_invoices |
Without API: pending + expires_at < now → expired |
Order is mandatory: sync first, then expire. Otherwise an invoice paid with a delay may become expired by TTL before sync sees the payment at the provider.
dsp_sync_invoices
- Reads
DJANGO_STRIPE_PLISIO_CRON["sync_invoices"]["enabled"]. IfFalse— warning to stdout and exit 0 (cron does not fail). - Options:
--dry-run(no DB writes),--batch-size N(overridesINVOICE_SYNC_BATCH_SIZE). - At the end prints a summary:
checked,paid,expired,cancelled,skipped,errors.
Example output:
Sync done: checked=12 paid=2 expired=1 cancelled=0 skipped=0 errors=0
dsp_expire_invoices
- Does not contact Stripe/Plisio.
- Requires
INVOICE_PENDING_TTL_HOURSwhen creating an invoice (theexpires_atfield).
What sync does per provider
Only invoices with status=pending and non-empty external_id are processed. Idempotency — via mark_invoice_paid and unique ProviderTransaction (same as webhook).
| Provider | API | «Paid» condition | Terminal without payment |
|---|---|---|---|
| Stripe | checkout.Session.retrieve(external_id) |
payment_status=paid and status=complete |
— (stays pending) |
| Plisio | GET /api/v1/operations/{txn_id} |
completed, confirmed |
expired → invoice expired; cancelled → cancelled |
| Plisio | — | mismatch |
not marked paid (log only, skipped) |
Limitation: sync does not poll Stripe subscriptions (StripeSubscription) — only invoice checkout sessions.
Settings
| Constant | Default | Description |
|---|---|---|
DJANGO_STRIPE_PLISIO_CRON |
{} |
Task dictionary: sync_invoices, expire_invoices |
…CRON["sync_invoices"]["enabled"] |
False |
Enable dsp_sync_invoices |
…CRON["sync_invoices"]["schedule"] |
— | Cron expression (5 fields, django-crontab) |
…CRON["expire_invoices"]["enabled"] |
False |
Enable dsp_expire_invoices |
…CRON["expire_invoices"]["schedule"] |
— | Cron expression |
DJANGO_STRIPE_PLISIO_INVOICE_SYNC_BATCH_SIZE |
100 |
Invoice limit per run |
DJANGO_STRIPE_PLISIO_INVOICE_SYNC_PROVIDERS |
all | E.g. ["stripe"] or ["plisio"] |
Example settings.py:
DJANGO_STRIPE_PLISIO_CRON = {
"sync_invoices": {
"enabled": True,
"schedule": "*/10 * * * *", # every 10 minutes
},
"expire_invoices": {
"enabled": True,
"schedule": "5 * * * *", # at :05 each hour — after sync
},
}
DJANGO_STRIPE_PLISIO_INVOICE_SYNC_BATCH_SIZE = 100
DJANGO_STRIPE_PLISIO_INVOICE_PENDING_TTL_HOURS = 24
Reading in code: PackageSettings.cron_config(), cron_task_enabled(), cron_task_schedule().
Programmatic invocation (without management command)
from django_stripe_plisio.payments.services.invoice_sync import sync_pending_invoices
result = sync_pending_invoices(batch_size=50, dry_run=False)
# result.checked, result.paid, result.expired_remote, ...
TTL expiration:
from django_stripe_plisio.billing.services import expire_pending_invoices
expire_pending_invoices() # -> int, number of updated invoices
django-crontab (recommended)
pip install "django-stripe-plisio[cron]"
# settings.py
INSTALLED_APPS = [
# ...
"django_crontab",
"django_stripe_plisio.billing",
"django_stripe_plisio.payments",
]
from django_stripe_plisio.cron import build_cronjobs
# Only package tasks with enabled=True and non-empty schedule
CRONJOBS = build_cronjobs()
# Your project tasks — as second argument:
# CRONJOBS = build_cronjobs(extra=[
# ("0 3 * * *", "myapp.tasks.cleanup"),
# ])
Deploy schedule to the server:
python manage.py crontab add
python manage.py crontab show
python manage.py crontab remove # when CRONJOBS changes — remove, then add
build_cronjobs() builds tuples:
("*/10 * * * *", "django.core.management.call_command", ["dsp_sync_invoices"])
The package does not override CRONJOBS in AppConfig.ready() — calling build_cronjobs() in settings is explicit and predictable.
system cron / Kubernetes
Without django-crontab:
# /etc/cron.d/billing — order: sync, then expire
*/10 * * * * www-data cd /app && python manage.py dsp_sync_invoices >> /var/log/dsp_sync.log 2>&1
5 * * * * www-data cd /app && python manage.py dsp_expire_invoices >> /var/log/dsp_expire.log 2>&1
Kubernetes CronJob — two Jobs with different schedule; sync has a more frequent interval, expire — with a small offset after sync.
When to enable sync in prod
| Situation | Recommendation |
|---|---|
| Webhooks configured and stable | Sync as backup every 5–15 min |
| Plisio/Stripe behind NAT, no public URL | Sync required until webhook is available |
| High load, many pending | Reduce INVOICE_SYNC_BATCH_SIZE, run cron more often |
| Test environment only | enabled: False or --dry-run |
Data models
Billing (django_stripe_plisio.billing)
Product
Business product or pricing plan.
| Field | Description |
|---|---|
code |
Unique slug (identifier in code) |
name, description |
Display name and description |
is_active |
Available for sale |
metadata |
Arbitrary JSON for your logic |
Price
Product price. Amounts only in minor units (cents, kopecks) + currency.
| Field | Description |
|---|---|
product |
FK to Product |
amount_minor |
Amount in minor units |
currency |
ISO 4217 (USD, EUR, …) |
billing_period |
one_time | month | year |
stripe_product_id, stripe_price_id |
Stripe object references (optional) |
Invoice
Invoice to a user.
| Field | Description |
|---|---|
user |
FK to AUTH_USER_MODEL |
status |
draft, pending, paid, expired, cancelled, failed |
provider |
stripe or plisio — payment channel for the invoice |
subtotal_minor, discount_minor, total_minor |
Amounts before/after discount |
payment_url |
Payment link at the provider |
external_id |
Session/transaction ID at the provider |
paid_at, expires_at |
Payment time / expiration |
metadata |
Your data (order id, source, …) |
InvoiceLine
Invoice line — snapshot of the price at issue time (changing Price does not affect old invoices).
PromoCode
Public promo code: percent or fixed discount, usage limits, validity period.
DiscountGrant
Private discount for a specific user (no code). Can be applied automatically on create_invoice(..., use_private_grant=True).
InvoiceDiscount
Snapshot of the applied discount on the invoice (link to PromoCode or DiscountGrant).
BalanceLedger
Append-only balance journal. Types: credit, debit, refund, adjustment, promo.
Entries are not edited — corrections only via a new ledger entry.
UserEntitlement
Granted access (period active_from / active_until). Created after payment; your logic can listen to the entitlement_granted signal.
Payments (django_stripe_plisio.payments)
PaymentAttempt
Each payment attempt: provider request/response, status, errors.
Proxy models for admin: StripePaymentAttempt, PlisioPaymentAttempt (filter by provider).
| Status | Meaning |
|---|---|
created |
Record created |
pending |
Session/invoice created at provider |
succeeded |
Success (confirmed by webhook) |
failed |
API error |
cancelled |
Cancelled |
WebhookEvent
Raw webhook with a unique idempotency_key. Redelivery does not duplicate crediting.
ProviderTransaction
Normalized provider transaction for reconciliation and reporting. Uniqueness: (provider, external_id).
StripeSubscription
Stripe recurring subscription (cards only). Plisio does not emulate auto-charge — only one-time crypto invoices.
Service layer
Public Python API (django_stripe_plisio.api)
from django_stripe_plisio import api
# Create invoice
invoice = api.create_invoice(
user=request.user,
price=price,
provider="stripe", # or "plisio"
quantity=1,
promo_code="SAVE10", # optional
metadata={"order_id": "42"},
)
# Create checkout at the invoice provider
attempt = api.create_checkout(invoice)
# attempt.payment_url — link for user redirect
# Apply promo code to pending invoice
invoice = api.apply_promo(invoice, "SAVE10")
# Private discount
api.grant_private_discount(
user,
discount_type="percent",
percent_value=15,
note="VIP",
)
# Balance and manual ledger entries
balance = api.get_user_balance(user, "USD")
api.record_ledger_entry(
user,
entry_type="debit",
amount_minor=-500,
currency="USD",
note="Service charge",
)
# Manually mark as paid (usually called from webhook)
api.mark_invoice_paid(invoice, external_id="cs_...")
billing/services.py (internal functions)
| Function | Purpose |
|---|---|
create_invoice |
Invoice + lines + discount + total_minor recalculation |
apply_promo |
Promo code on existing pending invoice |
calculate_discount_minor |
Discount calculation (percent / fixed) |
grant_private_discount |
Create DiscountGrant |
get_user_balance |
Sum(amount_minor) over ledger |
record_ledger_entry |
New ledger entry + balance_changed signal |
mark_invoice_paid |
Status paid, credit in ledger, entitlement, invoice_paid |
expire_pending_invoices |
pending + expired expires_at → expired |
Provider sync: payments.services.invoice_sync.sync_pending_invoices().
Payment providers
Common interface: BasePaymentProvider (create_checkout, verify_webhook, handle_webhook_event, sync_invoice_status).
Factory: payments.services.get_payment_service(provider) / create_checkout(invoice).
Stripe (StripePaymentService)
- Creates Checkout Session (
stripe.checkout.Session.create). - Mode
paymentforone_time,subscriptionformonth/year. - If
Pricehasstripe_price_id, the existing Stripe price is used. - Webhook:
checkout.session.completed→mark_invoice_paid. - Cron:
sync_invoice_status()→Session.retrieve→ same logic as webhook (_apply_checkout_session_paid).
Webhook setup in Stripe Dashboard:
- URL:
https://your-domain/billing/webhooks/stripe/ - Event:
checkout.session.completed - Secret →
DJANGO_STRIPE_PLISIO_STRIPE_WEBHOOK_SECRET
Plisio (PlisioPaymentService)
- Creates crypto invoice via
GET https://api.plisio.net/api/v1/invoices/new. - Callback verified via
verify_hash(SHA1 of sorted query + secret). - Statuses
completed/confirmed→ payment counted. - Cron:
GET /operations/{txn_id};mismatchdoes not move invoice topaid.
Callback URL in Plisio dashboard:
https://your-domain/billing/webhooks/plisio/
REST API
Requires pip install django-stripe-plisio[api] and DRF authentication (in demo — SessionAuthentication).
Catalog
GET /billing/products/
GET /billing/prices/
Invoices
GET /billing/invoices/
POST /billing/invoices/
GET /billing/invoices/{id}/
POST body:
{
"price_id": 1,
"quantity": 1,
"provider": "stripe",
"promo_code": "SAVE10"
}
User — from request.user; other users' invoices are not visible.
Payment
POST /billing/payments/stripe/checkout/
POST /billing/payments/plisio/invoice/
Body: { "invoice_id": 123 } — provider must match invoice.provider.
Balance
GET /billing/balance/ledger/?currency=USD
Webhooks
| Endpoint | Provider | CSRF |
|---|---|---|
POST /billing/webhooks/stripe/ |
Stripe | disabled |
POST /billing/webhooks/plisio/ |
Plisio | disabled |
Idempotency: keys like stripe:{event_id} and plisio:{txn_id}:{status}. Retry does not cause duplicate balance crediting.
Fallback: periodic dsp_sync_invoices uses the same mark_invoice_paid / status updates — repeated sync is safe.
Signals
Connected in the package's AppConfig.ready(). Import: django_stripe_plisio.signals.
from django.dispatch import receiver
from django_stripe_plisio.signals import invoice_paid, entitlement_granted
@receiver(invoice_paid)
def on_invoice_paid(sender, invoice, **kwargs):
# your logic: email, feature activation, CRM, …
...
@receiver(entitlement_granted)
def on_entitlement(sender, entitlement, invoice, **kwargs):
...
| Signal | When |
|---|---|
invoice_paid |
Invoice moved to paid |
payment_failed |
Checkout creation error |
balance_changed |
New entry in BalanceLedger |
entitlement_granted |
UserEntitlement created |
Django Admin
All main models are registered.
- Invoice — inline lines and discounts, filters by
provider,status,currency. - PaymentAttempt — general list + separate proxy Stripe / Plisio.
- BalanceLedger — read-only (no edit/delete).
- WebhookEvent — readonly
payloadfor audit.
Common scenarios
One-time plan payment (Stripe)
price = Price.objects.get(product__code="pro", currency="USD")
invoice = api.create_invoice(user, price, provider="stripe")
attempt = api.create_checkout(invoice)
return redirect(attempt.payment_url)
Balance top-up with crypto (Plisio)
invoice = api.create_invoice(user, price, provider="plisio")
attempt = api.create_checkout(invoice)
# UI shows attempt.payment_url or QR from Plisio response
Promo code + private discount
- Promo code:
create_invoice(..., promo_code="WELCOME"). - Private:
grant_private_discount(user, ...)— applied automatically if no promo code is passed.
Stripe subscription
Create a Price with billing_period=month (or year) and on checkout Stripe opens subscription mode. State is stored in StripeSubscription (webhook updates can be extended in your project).
Cron: webhooks + status catch-up
# settings.py — see «Scheduled tasks» section
DJANGO_STRIPE_PLISIO_CRON = {
"sync_invoices": {"enabled": True, "schedule": "*/10 * * * *"},
"expire_invoices": {"enabled": True, "schedule": "15 * * * *"},
}
pip install "django-stripe-plisio[cron]"
# settings: CRONJOBS = build_cronjobs()
python manage.py crontab add
Manual check:
python manage.py dsp_sync_invoices --dry-run
python manage.py dsp_sync_invoices
python manage.py dsp_expire_invoices
Invariants and constraints
- Money — only
amount_minor+currency, nofloat. - Ledger — append-only; corrections via a new entry.
- Invoice — price snapshot in
InvoiceLine; discount inInvoiceDiscount. - Webhooks — idempotent;
mark_invoice_paidsafe on retry. - Plisio — one-time invoices only, no recurring.
- DRF — optional; without it models, admin, services, webhooks work.
- Cron —
dsp_sync_invoicesbeforedsp_expire_invoices; sync does not cancel sessions at providers. - Business logic — minimal in the package; extend via signals and your handlers.
Development and testing
Demo project
pip install -e ".[dev]"
cd demo_project
python manage.py migrate
python manage.py runserver
PyCharm run configurations available: Demo: runserver, Demo: migrate, Tests: pytest.
Tests
pytest tests/ -v
ruff check src tests
mypy src/django_stripe_plisio
python -m build
Repository structure
django-stripe-plisio/
├── src/django_stripe_plisio/ # package source
├── demo_project/ # integration example
├── tests/ # pytest
├── pyproject.toml
└── README.md
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_stripe_plisio-0.1.1.tar.gz.
File metadata
- Download URL: django_stripe_plisio-0.1.1.tar.gz
- Upload date:
- Size: 63.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
efd04317b0c7d34a1affe9e195456b1f39f0c0d8710f0bdfc33dbeec1bf47663
|
|
| MD5 |
302dbcc63f4b0c4a6d005ba6a4c92346
|
|
| BLAKE2b-256 |
0c326e23b10ddb9e8f800518a5ce3388db2ad1a7a8cfa5b8ec9b3844c794b669
|
Provenance
The following attestation bundles were made for django_stripe_plisio-0.1.1.tar.gz:
Publisher:
publish.yml on svalench/django-stripe-plisio
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_stripe_plisio-0.1.1.tar.gz -
Subject digest:
efd04317b0c7d34a1affe9e195456b1f39f0c0d8710f0bdfc33dbeec1bf47663 - Sigstore transparency entry: 1745547434
- Sigstore integration time:
-
Permalink:
svalench/django-stripe-plisio@aad970586b1e26b7f032256c93c5e117da778358 -
Branch / Tag:
refs/tags/0.1.1 - Owner: https://github.com/svalench
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@aad970586b1e26b7f032256c93c5e117da778358 -
Trigger Event:
release
-
Statement type:
File details
Details for the file django_stripe_plisio-0.1.1-py3-none-any.whl.
File metadata
- Download URL: django_stripe_plisio-0.1.1-py3-none-any.whl
- Upload date:
- Size: 51.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7cc7088da55beb54450b0664867d4a7e35cc58b4cc3649e7c07ee1125e2c96b6
|
|
| MD5 |
0a79f1af5654406157c432fc95d58748
|
|
| BLAKE2b-256 |
51d96846151ee808372fcbb62e69153ee47093a79f903a0b165c1c9a83b4eb65
|
Provenance
The following attestation bundles were made for django_stripe_plisio-0.1.1-py3-none-any.whl:
Publisher:
publish.yml on svalench/django-stripe-plisio
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_stripe_plisio-0.1.1-py3-none-any.whl -
Subject digest:
7cc7088da55beb54450b0664867d4a7e35cc58b4cc3649e7c07ee1125e2c96b6 - Sigstore transparency entry: 1745547617
- Sigstore integration time:
-
Permalink:
svalench/django-stripe-plisio@aad970586b1e26b7f032256c93c5e117da778358 -
Branch / Tag:
refs/tags/0.1.1 - Owner: https://github.com/svalench
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@aad970586b1e26b7f032256c93c5e117da778358 -
Trigger Event:
release
-
Statement type: