Django plans paypal
Almost automatic integration between django-plans and django-paypal. This will add subscribe buttons to the order page and automatically confirm the Order after the payment.
Currently it is in experimetal stage, wher only recurring payments (subscribtions) are supported.
Documentation
The full documentation is at https://django-plans-paypal.readthedocs.io.
Quickstart
Install and configure django-plans and django-paypal apps. Capture mode is not yet supported, so PAYMENT_VARINANTS with 'capture': False will not get confirmed.
Install Django plans paypal:
pip install django-plans-paypal
Add it to your INSTALLED_APPS, before the plans:
INSTALLED_APPS = (
...
'payments',
'paypal.standard.ipn',
'plans_paypal',
...
)
Add your bussiness account e-mail address to settings:
PAYPAL_BUSSINESS_EMAIL = "foo@bar.com"
# To enable encrypted PayPal form:
PAYPAL_ENCRYPTED_FORM = True
PAYPAL_PRIVATE_CERT = os.path.join(BASE_DIR, 'certs/paypal_private.pem')
PAYPAL_PUBLIC_CERT = os.path.join(BASE_DIR, 'certs/paypal_public.pem')
PAYPAL_CERT = os.path.join(BASE_DIR, 'certs/paypal_cert.pem')
PAYPAL_CERT_ID = 'xxxxx'
Add Django plans_paypal to the URL patterns:
urlpatterns = [
...
url(r'^plans-paypal', include('plans_paypal.urls')),
...
]
Override django-plans class CreateOrderView so that get_success_url() returns url of paypal-payment view:
def get_success_url(self):
return reverse("paypal-payment", kwargs={'order_id': self.object.id})
Currency
The subscribe button charges the order’s currency (Order.currency, which django-plans sets from PLANS_CURRENCY). PayPal has to support it for your account, see https://developer.paypal.com/reference/currency-codes/. A subscription payment that arrives in any other currency is refused. HUF, JPY and TWD amounts are sent without decimals; an order whose total has a fraction of those currencies cannot be paid (the payment view raises ValueError), so price plans in whole units there.
Sandbox testing
Set following settings:
PAYPAL_TEST_BUSSINESS_EMAIL = "foo@bar.com"
PAYPAL_TEST = True
# For encrypted PayPal sandbox form:
PAYPAL_TEST_PRIVATE_CERT = os.path.join(BASE_DIR, 'certs/paypal_private.pem')
PAYPAL_TEST_PUBLIC_CERT = os.path.join(BASE_DIR, 'certs/paypal_public.pem')
PAYPAL_TEST_CERT = os.path.join(BASE_DIR, 'certs/paypal_sandbox_cert.pem')
PAYPAL_TEST_CERT_ID = 'xxxx'
Redirect user to paypal-payment-sandbox instead of paypal-payment view.
Sandbox IPNs are accepted only with PAYPAL_TEST = True set explicitly (django-paypal’s own default when it is unset does not count); otherwise they are refused with an exception and nothing is written. Live IPNs are processed either way, so one site can serve both buttons, but then a sandbox account can activate its paid plans: keep PAYPAL_TEST off production.
Features
TODO
Running Tests
Does the code actually work?
source <YOURVIRTUALENV>/bin/activate (myenv) $ pip install tox (myenv) $ tox
Credits
Tools used in rendering this package:
History
1.8.0 (2026-10-29)
Upgrade note: an abandoned PayPal checkout no longer turns its order into CANCELED; the order stays NEW until it expires (see the paypal-payment-failure entry below). Code that calls create_new_order() must drop its custom_ipn_data argument.
security: a pending subscr_payment (an eCheck clearing, recorded since 1.3.0) passes the same checks as a completed one before it is recorded: receiver_email, amount and currency. It was recorded as a payment in flight for whatever order and amount the edited button named, and support reads that record as “the customer is paying”. A refused one raises like a refused completed payment, nothing written
security: a renewal order gets the plan and pricing of the subscription’s first order. It took them from the plan_id and pricing_id in the IPN’s custom, a field of the subscribe button that the buyer can edit, so a subscription paying the cheapest order’s price renewed into whatever plan the buyer named. The IPN amount check passed, because it compares against the first order’s total. create_new_order() loses its custom_ipn_data argument
a renewal whose custom lacks plan_id no longer falls back to the user’s current plan, which could differ from the plan the subscription pays for
security: a subscr_payment (completed or pending) is checked against the RecurringUserPlan expectation (the amount updated after a PayPal-side change, 1.5.0) only when the IPN’s subscr_id is the expectation’s token, i.e. the subscription that armed it. Any armed expectation counted, whichever subscription or card had armed it: a buyer with a cheap monthly subscription could edit a yearly order’s button to the monthly price, and the new subscription’s first payment completed the yearly order. A renewal’s tax split falls back to such an expectation under the same condition
the IPN’s custom is parsed as JSON, which is what the button writes. ast.literal_eval remains only as the fallback for 0.4.5 buttons, which wrote the Python repr of a dict. Replacing null with None before literal_eval changed values that contain “null” (nullpointer@... became Nonepointer@...)
a custom that parses to anything but a dict with first_order_id (a list, a number, {}, an unhashable key) falls back to item_number and logs an error, as unparseable data already did. It crashed receive_ipn() with a TypeError or KeyError
security: the paypal-payment-failure view (PayPal’s cancel_return) no longer cancels the order. It did so on a GET, so any page could cancel a logged-in user’s unpaid order by making their browser load the URL. The order stays NEW, payable again as the failure page invites (“Please repeat payment process”), until it expires. A payment that arrives later completes it instead of creating a second order
requires django-plans >= 2.8.2 and django-author >= 1.2.0
tested on Python 3.15 (Django 6.1)
1.7.0 (2026-10-16)
Upgrade note - installations whose PLANS_CURRENCY is not USD: PayPal subscriptions created before this version keep renewing in USD and their payments are now refused. Earlier buttons charged USD whatever the order’s currency, and PayPal cannot change the currency of an existing subscription. A refused IPN leaves the subscriber charged but the plan not extended (no renewal order, the IPN view answers HTTP 500 and PayPal retries). Cancel those subscriptions at PayPal before upgrading (the plans stay active until their paid period ends) and have the subscribers subscribe again once this version is deployed, so the new button charges the order’s currency. To find them:
from django.conf import settings
from paypal.standard.ipn.models import PayPalIPN
ended = PayPalIPN.objects.filter(txn_type__in=["subscr_cancel", "subscr_eot"])
affected = set(
PayPalIPN.objects.filter(txn_type="subscr_payment", payment_status="Completed")
.exclude(mc_currency__iexact=settings.PLANS_CURRENCY)
.exclude(subscr_id__in=ended.values("subscr_id"))
.values_list("subscr_id", flat=True)
)
USD installations are not affected.
the subscribe button charges the order’s currency: it sends currency_code = order.currency (upper-cased). It never set one, so django-paypal’s form default USD was sent, and an installation whose PLANS_CURRENCY is not USD charged USD for orders invoiced in its own currency (the same number, a different amount of money). plans_paypal.currency.paypal_currency() is the one place that names the currency of an order’s subscription at PayPal
security: a completed subscr_payment IPN, first payment or renewal, whose mc_currency is not the subscription’s currency is refused like an amount mismatch, nothing written: Exception("Received currency doesn't match"). The comparison ignores case. The button is a client-side form, so a buyer could edit its currency_code and pay a 9.90 USD order with 9.90 JPY; the amount check passed and the order completed (a renewal was even invoiced in JPY). The IPN postback only proves PayPal sent the notification, not what it charged
a renewal order is invoiced in the subscription’s currency (paypal_currency() of the first order) instead of the IPN’s mc_currency. The notification decided the invoice currency, and create_new_order() can be called without the IPN checks; a lower-case mc_currency also leaked into the order and the RecurringUserPlan
the button amount (a3) has no decimals in HUF, JPY and TWD, which PayPal rejects even as .00; a total with a fraction of those currencies (e.g. 999 JPY + 21 % = 1208.79) raises ValueError in the payment view instead of PayPal refusing the button
admin: the PayPal payment list shows the charged currency next to the gross and fee
security: a sandbox subscription IPN (test_ipn=1) is refused unless PAYPAL_TEST = True is set: Exception("Sandbox IPN refused: PAYPAL_TEST is not enabled"), nothing written. django-paypal verifies a sandbox IPN against PayPal’s sandbox on any deployment and the button’s notify_url is editable, so a live site that had PAYPAL_TEST_BUSSINESS_EMAIL set completed orders and armed renewals paid with sandbox money. django-paypal’s own default (PAYPAL_TEST is True when unset) does not count: a sandbox setup relying on it has to set PAYPAL_TEST = True, as the README always said. Live IPNs are processed in either mode, unchanged. IPNs other than subscription ones are still left to other receivers
security: the payment page (paypal-payment and paypal-payment-sandbox) requires login and serves only the user’s own orders, someone else’s order is a 404, like django-plans’ OrderView. It looked the order up by id alone, so anyone, even anonymous, could open it for any order id and read the owner’s email from the button’s hidden custom field
the app pins default_auto_field to AutoField, the type its migrations use: with DEFAULT_AUTO_FIELD = BigAutoField (the global default since Django 6.0) makemigrations wrote a migration into the installed package
tested on Django 6.0 and 6.1 and on Python 3.14
Python 3.9 support dropped (end of life since October 2025): python_requires is >=3.10. GitHub’s ubuntu-latest runner moves to Ubuntu 26.04, which has no Python 3.9 build
packaging: the wheel no longer installs a top-level tests package (the test project’s settings and urls), which could shadow or be shadowed by a project’s own tests
development: make test and make coverage run the suite through manage.py test like CI and tox; they ran runtests.py tests, which found 0 tests. runtests.py is gone
CI: Dependabot checks GitHub Actions and pip requirements weekly
1.6.0 (2026-09-24)
renewal orders store the charged gross: create_new_order sets Order.gross_amount (django-plans 2.7.0) to the IPN’s mc_gross and derives the net with Order.net_from_gross() (coefficient method, tax = gross x rate / (100 + rate)). Multiplying a net cent value by a rate cannot reach every gross cent value, so a renewal after a tax-rate change could only pick the nearest net and was invoiced a cent below the charge (e.g. 14.89 at 21 %: 12.30 -> 14.88, 12.31 -> 14.90). The invoice now carries exactly what PayPal charged; the “closest total” workaround and its warning are gone
when the current rate cannot be determined, the copied values (RecurringUserPlan expectation, then the first order) are chosen by which one’s total equals the charged gross. Previously the expectation won even when PayPal had charged the first order’s total, so the order carried an amount other than the one received. If neither matches, Order.save() now rejects the order instead of invoicing an amount that was not charged
requires django-plans >= 2.7.0
1.5.0 (2026-08-25)
tests: full 100 % line and branch coverage, and CI now gates on it (coverage report --fail-under=100). New coverage includes the sandbox business-email validation, stale cancellations for users without a recurring plan, VAT IDs reaching the taxation policy in full form, encrypted-form certificate wiring (sandbox vs. live), and a characterization of the week-unit period conversion (fractional p3 - PayPal documents an integer; left as-is deliberately, live subscriptions depend on current behaviour)
the received-amount check accepts, besides the first order’s total, the total implied by the armed RecurringUserPlan (net x current tax). This makes legitimate subscription-amount updates on PayPal’s side (e.g. after a VAT change) processable: update the RecurringUserPlan expectation, then change the amount at PayPal. Renewal orders without a taxation policy configured now also prefer that expectation over the first order’s frozen copy, so the invoice matches what was actually charged.
1.4.0 (2026-08-20)
admin: PayPalPaymentAdmin now extends RelatedFieldAdmin - the __ paths in list_display only work on plain ModelAdmin since Django 5.1, so the admin was broken on Django 4.2/5.0
debug print() calls removed from the IPN hook and the payment view (the view printed the full payment dict incl. customer e-mail into logs on every order page view); the hook logs a single logger.debug line instead
tests: admin (system checks + changelist), IPN with empty custom data, cancellation with a foreign subscr_id, PayPal duration-unit selection and sandbox-flag isolation of the payment form (coverage 84 % -> 93 %)
release tooling: make release/make sdist use build + twine (setup.py sdist upload has been dead on PyPI for years), the broken setup.py publish shortcut and the stale .travis.yml are gone
CI: test matrix updated to Django 4.2-5.2 on Python 3.9-3.13 (dropped EOL Django 3.2-4.1 and Python 3.8), GitHub Actions bumped to current versions
CI: Codecov upload repaired - the deprecated codecov CLI uploader has been dead since 2023 (uploads failed silently, so no reports or PR comments appeared); now codecov-action@v5 with OIDC
packaging: classifiers and python_requires reflect the supported versions
renewal orders recalculate the tax rate for the user’s current billing data instead of copying it from the subscription’s first order forever. PayPal charges a fixed gross, so the new rate is applied top-down: the gross stays, the net amount adjusts (at most one cent off when no cent-exact net exists for the new rate). Without PLANS_TAXATION_POLICY, without BillingInfo, or on a failed VIES/rate lookup the previous behaviour (copied tax) is kept.
1.3.0 (2026-08-19)
receive_ipn now processes each IPN in a single database transaction: a failure while completing the order no longer leaves an armed RecurringUserPlan and a recorded payment behind, so PayPal’s IPN retries start from a clean slate instead of dying on the leftover state as duplicates.
pending subscription payments (typically eChecks clearing) are recorded as PayPalPayment rows instead of being silently dropped – an in-flight payment is not a decline.
1.2.0 (2026-05-20)
PaymentFailureView: redirect already-completed orders to the success page (with a warning log) instead of raising ValueError. PayPal can send the buyer to the cancel_return URL even after the IPN has marked the order as completed (browser back button, stale tabs, edge cases in the PayPal flow); the previous behaviour produced unhandled 500s for paid users. Reverses the 0.7.1 behaviour change.
handle empty/whitespace PayPal IPN custom data without logging an error; also catch ValueError from ast.literal_eval so future Python releases can’t break the parser
admin: add paypal_ipn__txn_id column to PayPalPaymentAdmin.list_display
drop conflicting isort lines_after_imports = 2 setting (project already uses profile = black) and reformat affected files; the lint CI job is green again
1.1.0 (2025-06-20)
add more fields to admin list_display
1.0.1 (2025-05-29)
fixes to taking the data from custom_data
1.0.0 (2025-05-23)
create order more robust: take the data from custom_data
Support Django up to 5.2
0.7.1 (2024-04-23)
fail if PaymentFailureView is requested on completed orders
0.7.0 (2024-04-12)
migrate code to RecurringUserPlan.renewal_triggered_by
migrate data of RecurringUserPlans with payment_provider=”paypal-recurring” and renewal_triggered_by=TASK to renewal_triggered_by=OTHER
0.6.0 (2023-03-22)
Fix amount received on recurring payments, add tests
0.5.1 (2023-02-11)
allow only logged users to access the failure view
0.5.0 (2022-12-14)
Fix tax received on recurring payments
More robust receiving original Order ID (if PayPal fails to handle custom_data)
update to Django 4.1
fix completing recurring payments if first order status is returned
only log error if custom data can’t be parsed
0.4.10 (2022-04-29)
fix problem with duplicate payments
PayPalPaymentAdmin: display connected IPN fields
0.4.9 (2022-03-07)
make parsing custom_data more robust
0.4.8 (2022-01-19)
fix last release
add tests and test on all supported Django/Python versions through GitHub actions
0.4.7 (2022-01-13)
fix problem if there was ‘ in custom payment data
0.4.6 (2021-12-02)
fix problem with creating bad JSON
0.4.5 (2021-12-01)
fix IPN field editing in PayPalPaymentAdmin
store also user e-mail in custom to enable search in IPN admin
0.4.4 (2021-12-01)
create PayPalPayment before completing the order
0.4.3 (2021-11-30)
fix problem with recurring payments
add created/modified/author auto fields
0.4.2 (2021-11-08)
fix problem with other IPN types
0.4.1 (2021-10-18)
cancel order after returning to failure URL
0.4.0 (2021-10-18)
allow to set up encrypted PayPal form
0.3.0 (2021-10-15)
fixes and improvements to the sandbox/production functionality
reverse sandbox logic - if no PAYPAL_TEST, the sandbox view would return production so nobody can pay through sandbox on production server
0.2.2 (2021-10-12)
fix foregotten pudb
0.2.1 (2021-10-12)
add sandbox view, so both production and sandbox can be used on one server
0.2.0 (2021-10-11)
fix periods to complain with PayPal maximal durations
0.1.0 (2021-10-08)
hook ipn.PayPalIpn object with plans.Order (for later usage e.g. determining PayPal fee)
set plan renewal for django-plans
0.0.2 (2018-08-05)
Payment process without capturing should work
Automatic buttons generation
0.0.1 (2018-07-23)
First release on PyPI.
Metadata
Release files for django-plans-paypal 1.8.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| django_plans_paypal-1.8.0.tar.gz | 32.9 kB | Without provenance | Details for django_plans_paypal-1.8.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| django_plans_paypal-1.8.0-py3-none-any.whl | Python 3 | none | any | Details for django_plans_paypal-1.8.0-py3-none-any.whl |
Total release size: 68.1 kB
Release files / django_plans_paypal-1.8.0.tar.gz
| Download URL | django_plans_paypal-1.8.0.tar.gz |
|---|---|
| Size | 32.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
e05e3ce003ad88eae6319095c35b819dca69eb770aef1d69dc5ce199cfbbe735
|
|
BLAKE2b-256 checksum How to use checksums |
deab864da58d654858d1772032cfd85c4f9e468fe561e8f61de3fff6fdf63133
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|
Release files / django_plans_paypal-1.8.0-py3-none-any.whl
| Download URL | django_plans_paypal-1.8.0-py3-none-any.whl |
|---|---|
| Size | 35.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
266b26161eb34bf39408c2891cbeeaa59adc420fa6ea1b58f6ccbb69e05ba6cb
|
|
BLAKE2b-256 checksum How to use checksums |
5e9c7ad9a09cc14ad082b894bf6e8630304c129c798cfc200cd7720fcd3690df
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|
Security for django-plans-paypal 1.8.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.