switch to a new version of payments module
up to date with payment processors, pull-focused, and as a reusable appmaster
parent
18ffc0af5f
commit
72bc2d34a4
@ -0,0 +1,76 @@
|
||||
# Generated by Django 3.2.5 on 2021-07-22 00:43
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('lambdainst', '0004_auto_20200829_2054'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='giftcode',
|
||||
old_name='free_only',
|
||||
new_name='new_clients_only',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='giftcode',
|
||||
name='created_by',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='giftcode',
|
||||
name='for_plans',
|
||||
field=models.CharField(blank=True, help_text='Valid only for the following plans, separated by commas. ("1m,6m", "12m", ...) (empty: any plan allowed)', max_length=100),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='giftcode',
|
||||
name='valid_after',
|
||||
field=models.DateTimeField(blank=True, help_text='Valid only after the date. (empty: no limit)', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='giftcode',
|
||||
name='valid_before',
|
||||
field=models.DateTimeField(blank=True, help_text='Valid only before the date. (empty: no limit)', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='giftcode',
|
||||
name='value',
|
||||
field=models.IntegerField(blank=True, help_text='Integer between 0 and 100. If the total is below 0.50$, it will be free and skip the payment', null=True, verbose_name='Discount %'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='giftcode',
|
||||
name='available',
|
||||
field=models.BooleanField(default=True, help_text='Coupon invalid when unchecked.'),
|
||||
),
|
||||
|
||||
migrations.SeparateDatabaseAndState(
|
||||
state_operations=[
|
||||
migrations.RemoveField(
|
||||
model_name='giftcodeuser',
|
||||
name='code',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='giftcodeuser',
|
||||
name='user',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='GiftCode',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='GiftCodeUser',
|
||||
),
|
||||
],
|
||||
database_operations=[
|
||||
migrations.AlterModelTable(
|
||||
name='GiftCode',
|
||||
table='payments_coupon',
|
||||
),
|
||||
migrations.AlterModelTable(
|
||||
name='GiftCodeUser',
|
||||
table='payments_couponuser',
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
@ -1,137 +0,0 @@
|
||||
import json
|
||||
from django.shortcuts import resolve_url
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.text import Truncator
|
||||
from .models import Payment, Subscription, Feedback
|
||||
|
||||
|
||||
def subscr_mark_as_cancelled(modeladmin, request, queryset):
|
||||
queryset.update(status='cancelled')
|
||||
subscr_mark_as_cancelled.short_description = _("Mark as cancelled (do not actually cancel)")
|
||||
|
||||
|
||||
def link(text, url):
|
||||
if not url:
|
||||
return text
|
||||
if not text:
|
||||
text = url
|
||||
return format_html('<a href="{}">{}</a>', url, text)
|
||||
|
||||
def json_format(code):
|
||||
j = json.dumps(code, indent=2)
|
||||
return format_html("<pre>{}</pre>", j)
|
||||
|
||||
|
||||
class PaymentAdmin(admin.ModelAdmin):
|
||||
model = Payment
|
||||
list_display = ('user', 'backend', 'status', 'amount', 'paid_amount', 'created')
|
||||
list_filter = ('backend_id', 'status')
|
||||
|
||||
fieldsets = (
|
||||
(None, {
|
||||
'fields': ('backend', 'user_link', 'subscription_link', 'time', 'status',
|
||||
'status_message'),
|
||||
}),
|
||||
(_("Payment Data"), {
|
||||
'fields': ('amount_fmt', 'paid_amount_fmt',
|
||||
'backend_extid_link', 'backend_data_fmt'),
|
||||
}),
|
||||
)
|
||||
|
||||
readonly_fields = ('backend', 'user_link', 'time', 'status', 'status_message',
|
||||
'amount_fmt', 'paid_amount_fmt', 'subscription_link',
|
||||
'backend_extid_link', 'backend_data_fmt')
|
||||
search_fields = ('user__username', 'user__email', 'backend_extid', 'backend_data')
|
||||
|
||||
def backend(self, object):
|
||||
try:
|
||||
return object.backend.backend_verbose_name
|
||||
except KeyError:
|
||||
return "#" + object.backend_id
|
||||
|
||||
def backend_data_fmt(self, object):
|
||||
return json_format(object.backend_data)
|
||||
|
||||
def backend_extid_link(self, object):
|
||||
try:
|
||||
ext_url = object.backend.get_ext_url(object)
|
||||
return link(object.backend_extid, ext_url)
|
||||
except KeyError:
|
||||
return "#" + object.backend_id
|
||||
|
||||
def amount_fmt(self, object):
|
||||
return '%.2f %s' % (object.amount / 100, object.currency_name)
|
||||
amount_fmt.short_description = _("Amount")
|
||||
|
||||
def paid_amount_fmt(self, object):
|
||||
return '%.2f %s' % (object.paid_amount / 100, object.currency_name)
|
||||
paid_amount_fmt.short_description = _("Paid amount")
|
||||
|
||||
def user_link(self, object):
|
||||
change_url = resolve_url('admin:auth_user_change', object.user.id)
|
||||
return link(object.user.username, change_url)
|
||||
user_link.short_description = 'User'
|
||||
|
||||
def subscription_link(self, object):
|
||||
change_url = resolve_url('admin:payments_subscription_change',
|
||||
object.subscription.id)
|
||||
return link(object.subscription.id, change_url)
|
||||
subscription_link.short_description = 'Subscription'
|
||||
|
||||
|
||||
class SubscriptionAdmin(admin.ModelAdmin):
|
||||
model = Subscription
|
||||
list_display = ('user', 'created', 'status', 'backend', 'backend_extid')
|
||||
list_filter = ('backend_id', 'status')
|
||||
readonly_fields = ('user_link', 'backend', 'created', 'status',
|
||||
'last_confirmed_payment', 'payments_links',
|
||||
'backend_extid_link', 'backend_data_fmt')
|
||||
search_fields = ('user__username', 'user__email', 'backend_extid', 'backend_data')
|
||||
actions = (subscr_mark_as_cancelled,)
|
||||
fieldsets = (
|
||||
(None, {
|
||||
'fields': ('backend', 'user_link', 'payments_links', 'status',
|
||||
'last_confirmed_payment'),
|
||||
}),
|
||||
(_("Payment Data"), {
|
||||
'fields': ('backend_extid_link', 'backend_data_fmt'),
|
||||
}),
|
||||
)
|
||||
|
||||
def backend(self, object):
|
||||
return object.backend.backend_verbose_name
|
||||
|
||||
def backend_data_fmt(self, object):
|
||||
return json_format(object.backend_data)
|
||||
|
||||
def user_link(self, object):
|
||||
change_url = resolve_url('admin:auth_user_change', object.user.id)
|
||||
return link(object.user.id, change_url)
|
||||
user_link.short_description = 'User'
|
||||
|
||||
def payments_links(self, object):
|
||||
count = Payment.objects.filter(subscription=object).count()
|
||||
payments_url = resolve_url('admin:payments_payment_changelist')
|
||||
url = "%s?subscription__id__exact=%s" % (payments_url, object.id)
|
||||
return link("%d payment(s)" % count, url)
|
||||
payments_links.short_description = 'Payments'
|
||||
|
||||
def backend_extid_link(self, object):
|
||||
ext_url = object.backend.get_subscr_ext_url(object)
|
||||
return link(object.backend_extid, ext_url)
|
||||
backend_extid_link.allow_tags = True
|
||||
|
||||
class FeedbackAdmin(admin.ModelAdmin):
|
||||
model = Feedback
|
||||
list_display = ('user', 'created', 'short_message')
|
||||
readonly_fields = ('user', 'created', 'message', 'subscription')
|
||||
|
||||
def short_message(self, obj):
|
||||
return Truncator(obj.message).chars(80)
|
||||
|
||||
admin.site.register(Payment, PaymentAdmin)
|
||||
admin.site.register(Subscription, SubscriptionAdmin)
|
||||
admin.site.register(Feedback, FeedbackAdmin)
|
||||
|
@ -1,8 +0,0 @@
|
||||
# flake8: noqa
|
||||
|
||||
from .base import BackendBase, ManualBackend
|
||||
from .paypal import PaypalBackend
|
||||
from .bitcoin import BitcoinBackend
|
||||
from .stripe import StripeBackend
|
||||
from .coinpayments import CoinPaymentsBackend
|
||||
|
@ -1,56 +0,0 @@
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
|
||||
class BackendBase:
|
||||
backend_id = None
|
||||
backend_verbose_name = ""
|
||||
backend_display_name = ""
|
||||
backend_enabled = False
|
||||
backend_has_recurring = False
|
||||
|
||||
def __init__(self, settings):
|
||||
pass
|
||||
|
||||
def new_payment(self, payment):
|
||||
""" Initialize a payment and returns an URL to redirect the user.
|
||||
Can return a HTML string that will be sent back to the user in a
|
||||
default template (like a form) or a HTTP response (like a redirect).
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def callback(self, payment, request):
|
||||
""" Handle a callback """
|
||||
raise NotImplementedError()
|
||||
|
||||
def callback_subscr(self, payment, request):
|
||||
""" Handle a callback (recurring payments) """
|
||||
raise NotImplementedError()
|
||||
|
||||
def cancel_subscription(self, subscr):
|
||||
""" Cancel a subscription """
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_info(self):
|
||||
""" Returns some status (key, value) list """
|
||||
return ()
|
||||
|
||||
def get_ext_url(self, payment):
|
||||
""" Returns URL to external payment view, or None """
|
||||
return None
|
||||
|
||||
def get_subscr_ext_url(self, subscr):
|
||||
""" Returns URL to external payment view, or None """
|
||||
return None
|
||||
|
||||
|
||||
class ManualBackend(BackendBase):
|
||||
""" Manual backend used to store and display informations about a
|
||||
payment processed manually.
|
||||
More a placeholder than an actual payment beckend, everything raises
|
||||
NotImplementedError().
|
||||
"""
|
||||
|
||||
backend_id = 'manual'
|
||||
backend_verbose_name = _("Manual")
|
||||
|
||||
|
@ -1,108 +0,0 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.shortcuts import redirect
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.urls import reverse
|
||||
from constance import config as site_config
|
||||
|
||||
from .base import BackendBase
|
||||
|
||||
|
||||
class BitcoinBackend(BackendBase):
|
||||
""" Bitcoin backend.
|
||||
Connects to a bitcoind.
|
||||
"""
|
||||
backend_id = 'bitcoin'
|
||||
backend_verbose_name = _("Bitcoin")
|
||||
backend_display_name = _("Bitcoin")
|
||||
|
||||
COIN = 100000000
|
||||
|
||||
def __init__(self, settings):
|
||||
from bitcoin import SelectParams
|
||||
from bitcoin.rpc import Proxy
|
||||
|
||||
self.account = settings.get('account', 'ccvpn3')
|
||||
|
||||
chain = settings.get('chain')
|
||||
if chain:
|
||||
SelectParams(chain)
|
||||
|
||||
self.url = settings.get('url')
|
||||
if not self.url:
|
||||
return
|
||||
|
||||
self.make_rpc = lambda: Proxy(self.url)
|
||||
self.rpc = self.make_rpc()
|
||||
self.backend_enabled = True
|
||||
|
||||
@property
|
||||
def btc_value(self):
|
||||
return site_config.BTC_EUR_VALUE
|
||||
|
||||
def new_payment(self, payment):
|
||||
rpc = self.make_rpc()
|
||||
|
||||
# bitcoins amount = (amount in cents) / (cents per bitcoin)
|
||||
btc_price = round(Decimal(payment.amount) / self.btc_value, 5)
|
||||
|
||||
address = str(rpc.getnewaddress(self.account))
|
||||
|
||||
msg = _("Please send %(amount)s BTC to %(address)s")
|
||||
payment.status_message = msg % dict(amount=str(btc_price), address=address)
|
||||
payment.backend_extid = address
|
||||
payment.backend_data = dict(btc_price=str(btc_price), btc_address=address)
|
||||
payment.save()
|
||||
return redirect(reverse('payments:view', args=(payment.id,)))
|
||||
|
||||
def check(self, payment):
|
||||
rpc = self.make_rpc()
|
||||
|
||||
if payment.status != 'new':
|
||||
return
|
||||
|
||||
btc_price = payment.backend_data.get('btc_price')
|
||||
address = payment.backend_data.get('btc_address')
|
||||
if not btc_price or not address:
|
||||
return
|
||||
|
||||
btc_price = Decimal(btc_price)
|
||||
|
||||
received = Decimal(rpc.getreceivedbyaddress(address)) / self.COIN
|
||||
payment.paid_amount = int(received * self.btc_value)
|
||||
payment.backend_data['btc_paid_price'] = str(received)
|
||||
|
||||
if received >= btc_price:
|
||||
payment.user.vpnuser.add_paid_time(payment.time)
|
||||
payment.user.vpnuser.on_payment_confirmed(payment)
|
||||
payment.user.vpnuser.save()
|
||||
|
||||
payment.status = 'confirmed'
|
||||
|
||||
payment.save()
|
||||
|
||||
def get_info(self):
|
||||
rpc = self.make_rpc()
|
||||
|
||||
try:
|
||||
info = rpc.getinfo()
|
||||
if not info:
|
||||
return [(_("Status"), "Error: got None")]
|
||||
except Exception as e:
|
||||
return [(_("Status"), "Error: " + repr(e))]
|
||||
v = info.get('version', 0)
|
||||
return (
|
||||
(_("Bitcoin value"), "%.2f €" % (self.btc_value / 100)),
|
||||
(_("Testnet"), info['testnet']),
|
||||
(_("Balance"), '{:f}'.format(info['balance'] / self.COIN)),
|
||||
(_("Blocks"), info['blocks']),
|
||||
(_("Bitcoind version"), '.'.join(str(v // 10 ** (2 * i) % 10 ** (2 * i))
|
||||
for i in range(3, -1, -1))),
|
||||
)
|
||||
|
||||
def get_ext_url(self, payment):
|
||||
if not payment.backend_extid:
|
||||
return None
|
||||
return 'https://blockstream.info/address/%s' % payment.backend_extid
|
||||
|
||||
|
@ -1,301 +0,0 @@
|
||||
import math
|
||||
from decimal import Decimal
|
||||
|
||||
from django.shortcuts import redirect
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.urls import reverse
|
||||
from constance import config as site_config
|
||||
|
||||
from django.conf import settings as project_settings
|
||||
from .base import BackendBase
|
||||
|
||||
|
||||
import hmac
|
||||
import hashlib
|
||||
import requests
|
||||
import logging
|
||||
import json
|
||||
from urllib.parse import urlencode
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CoinPaymentsError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class CoinPayments:
|
||||
def __init__(self, pkey, skey, api_url=None):
|
||||
self.public_key = pkey
|
||||
self.secret_key = skey.encode('utf-8')
|
||||
self.api_url = api_url or 'https://www.coinpayments.net/api.php'
|
||||
|
||||
def _sign(self, params):
|
||||
body = urlencode(params).encode('utf-8')
|
||||
mac = hmac.new(self.secret_key, body, hashlib.sha512)
|
||||
return body, mac.hexdigest()
|
||||
|
||||
def _request(self, cmd, params):
|
||||
params.update({
|
||||
'cmd': cmd,
|
||||
'key': self.public_key,
|
||||
'format': 'json',
|
||||
'version': 1,
|
||||
})
|
||||
print(params)
|
||||
post_body, mac = self._sign(params)
|
||||
|
||||
headers = {
|
||||
'HMAC': mac,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
}
|
||||
|
||||
r = requests.post(self.api_url, data=post_body,
|
||||
headers=headers)
|
||||
try:
|
||||
r.raise_for_status()
|
||||
j = r.json()
|
||||
except Exception as e:
|
||||
raise CoinPaymentsError(str(e)) from e
|
||||
|
||||
if j.get('error') == 'ok':
|
||||
return j.get('result')
|
||||
else:
|
||||
raise CoinPaymentsError(j.get('error'))
|
||||
|
||||
def create_transaction(self, **params):
|
||||
assert 'amount' in params
|
||||
assert 'currency1' in params
|
||||
assert 'currency2' in params
|
||||
return self._request('create_transaction', params)
|
||||
|
||||
def get_account_info(self, **params):
|
||||
return self._request('get_basic_info', params)
|
||||
|
||||
def get_rates(self, **params):
|
||||
return self._request('rates', params)
|
||||
|
||||
def get_balances(self, **params):
|
||||
return self._request('balances', params)
|
||||
|
||||
def get_deposit_address(self, **params):
|
||||
assert 'currency' in params
|
||||
return self._request('get_deposit_address', params)
|
||||
|
||||
def get_callback_address(self, **params):
|
||||
assert 'currency' in params
|
||||
return self._request('get_callback_address', params)
|
||||
|
||||
def get_tx_info(self, **params):
|
||||
assert 'txid' in params
|
||||
return self._request('get_tx_info', params)
|
||||
|
||||
def get_tx_info_multi(self, ids=None, **params):
|
||||
if ids is not None:
|
||||
params['txid'] = '|'.join(str(i) for i in ids)
|
||||
assert 'txid' in params
|
||||
return self._request('get_tx_info_multi', params)
|
||||
|
||||
def get_tx_ids(self, **params):
|
||||
return self._request('get_tx_ids', params)
|
||||
|
||||
def create_transfer(self, **params):
|
||||
assert 'amount' in params
|
||||
assert 'currency' in params
|
||||
assert 'merchant' in params or 'pbntag' in params
|
||||
return self._request('create_transfer', params)
|
||||
|
||||
def create_withdrawal(self, **params):
|
||||
assert 'amount' in params
|
||||
assert 'currency' in params
|
||||
assert 'address' in params or 'pbntag' in params
|
||||
return self._request('create_withdrawal', params)
|
||||
|
||||
def create_mass_withdrawal(self, **params):
|
||||
assert 'wd' in params
|
||||
return self._request('create_mass_withdrawal', params)
|
||||
|
||||
def convert(self, **params):
|
||||
assert 'amount' in params
|
||||
assert 'from' in params
|
||||
assert 'to' in params
|
||||
return self._request('convert', params)
|
||||
|
||||
def get_withdrawal_history(self, **params):
|
||||
return self._request('get_withdrawal_history', params)
|
||||
|
||||
def get_withdrawal_info(self, **params):
|
||||
assert 'id' in params
|
||||
return self._request('get_withdrawal_info', params)
|
||||
|
||||
def get_conversion_info(self, **params):
|
||||
assert 'id' in params
|
||||
return self._request('get_conversion_info', params)
|
||||
|
||||
def get_pbn_info(self, **params):
|
||||
assert 'pbntag' in params
|
||||
return self._request('get_pbn_info', params)
|
||||
|
||||
def get_pbn_list(self, **params):
|
||||
return self._request('get_pbn_list', params)
|
||||
|
||||
def update_pbn_tag(self, **params):
|
||||
assert 'tagid' in params
|
||||
return self._request('update_pbn_tag', params)
|
||||
|
||||
def claim_pbn_tag(self, **params):
|
||||
assert 'tagid' in params
|
||||
assert 'name' in params
|
||||
return self._request('claim_pbn_tag', params)
|
||||
|
||||
|
||||
class IpnError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def ipn_assert(request, remote, local, key=None, delta=None):
|
||||
if (delta is None and remote != local) or (delta is not None and not math.isclose(remote, local, abs_tol=delta)):
|
||||
logger.debug("Invalid IPN %r: local=%r remote=%r",
|
||||
key, local, remote)
|
||||
raise IpnError("Unexpected value: %s" % key)
|
||||
|
||||
|
||||
def ipn_assert_post(request, key, local):
|
||||
remote = request.POST.get(key)
|
||||
ipn_assert(request, remote, local, key=key)
|
||||
|
||||
|
||||
class CoinPaymentsBackend(BackendBase):
|
||||
backend_id = 'coinpayments'
|
||||
backend_verbose_name = _("CoinPayments")
|
||||
backend_display_name = _("Cryptocurrencies")
|
||||
backend_has_recurring = False
|
||||
|
||||
def __init__(self, settings):
|
||||
self.merchant_id = settings.get('merchant_id')
|
||||
self.currency = settings.get('currency', 'EUR')
|
||||
self.api_base = settings.get('api_base', None)
|
||||
self.title = settings.get('title', 'VPN Payment')
|
||||
self.secret = settings.get('secret', '').encode('utf-8')
|
||||
|
||||
if self.merchant_id and self.secret:
|
||||
self.backend_enabled = True
|
||||
|
||||
def new_payment(self, payment):
|
||||
ROOT_URL = project_settings.ROOT_URL
|
||||
params = {
|
||||
'cmd': '_pay',
|
||||
'reset': '1',
|
||||
'want_shipping': '0',
|
||||
'merchant': self.merchant_id,
|
||||
'currency': self.currency,
|
||||
'amountf': '%.2f' % (payment.amount / 100),
|
||||
'item_name': self.title,
|
||||
'ipn_url': ROOT_URL + reverse('payments:cb_coinpayments', args=(payment.id,)),
|
||||
'success_url': ROOT_URL + reverse('payments:view', args=(payment.id,)),
|
||||
'cancel_url': ROOT_URL + reverse('payments:cancel', args=(payment.id,)),
|
||||
}
|
||||
|
||||
payment.status_message = _("Waiting for CoinPayments to confirm the transaction... " +
|
||||
"It can take up to a few minutes...")
|
||||
payment.save()
|
||||
|
||||
form = '<form action="https://www.coinpayments.net/index.php" method="POST" id="cp-form">'
|
||||
for k, v in params.items():
|
||||
form += '<input type="hidden" name="%s" value="%s" />' % (k, v)
|
||||
form += '''
|
||||
<img src="/static/img/spinner.gif" style="margin: auto;" alt="redirecting..." id="cp-spinner" />
|
||||
<input type="submit" class="button" name="submitbutton" value="Continue" />
|
||||
</form>
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function(event) {{
|
||||
var f = document.getElementById("cp-form");
|
||||
f.elements["submitbutton"].style.display = "none";
|
||||
document.getElementById("cp-spinner").style.display = "block";
|
||||
f.submit();
|
||||
}});
|
||||
</script>
|
||||
'''
|
||||
return form
|
||||
|
||||
def handle_ipn(self, payment, request):
|
||||
sig = request.META.get('HTTP_HMAC')
|
||||
if not sig:
|
||||
raise IpnError("Missing HMAC")
|
||||
|
||||
mac = hmac.new(self.secret, request.body, hashlib.sha512).hexdigest()
|
||||
|
||||
# Sanity checks, if it fails the IPN is to be ignored
|
||||
ipn_assert(request, sig, mac, 'HMAC')
|
||||
ipn_assert_post(request, 'ipn_mode', 'hmac')
|
||||
ipn_assert_post(request, 'merchant', self.merchant_id)
|
||||
|
||||
try:
|
||||
status = int(request.POST.get('status'))
|
||||
except ValueError:
|
||||
raise IpnError("Invalid status (%r)" % status)
|
||||
|
||||
# Some states are final (can't cancel a timeout or refund)
|
||||
if payment.status not in ('new', 'confirmed', 'error'):
|
||||
m = "Unexpected state change for %s: is %s, received status=%r" % (
|
||||
payment.id, payment.status, status
|
||||
)
|
||||
raise IpnError(m)
|
||||
|
||||
# whatever the status, we can safely update the text and save the tx id
|
||||
payment.status_text = request.POST.get('status_text') or payment.status_text
|
||||
payment.backend_extid = request.POST.get('txn_id')
|
||||
|
||||
received_amount = request.POST.get('amount1')
|
||||
if received_amount:
|
||||
payment.paid_amount = float(received_amount) * 100
|
||||
|
||||
# And now the actual processing
|
||||
if status == 1: # A payment is confirmed paid
|
||||
if payment.status != 'confirmed':
|
||||
if payment.paid_amount != payment.amount:
|
||||
ipn_assert(request, payment.paid_amount, payment.amount, 'paid',
|
||||
delta=10)
|
||||
vpnuser = payment.user.vpnuser
|
||||
vpnuser.add_paid_time(payment.time)
|
||||
vpnuser.on_payment_confirmed(payment)
|
||||
vpnuser.save()
|
||||
|
||||
# We save the new state *at the end*
|
||||
# (it will be retried if there's an error)
|
||||
payment.status = 'confirmed'
|
||||
payment.status_message = None
|
||||
payment.save()
|
||||
|
||||
elif status > 1: # Waiting (that's further confirmation about funds getting moved)
|
||||
# we have nothing to do, except updating status_text
|
||||
payment.save()
|
||||
return
|
||||
|
||||
elif status == -1: # Cancel / Time out
|
||||
payment.status = 'cancelled'
|
||||
payment.save()
|
||||
|
||||
elif status == -2: # A refund
|
||||
if payment.status == 'confirmed': # (paid -> refunded)
|
||||
payment.status = 'refunded'
|
||||
# TODO
|
||||
|
||||
elif status <= -3: # Unknown error
|
||||
payment.status = 'error'
|
||||
payment.save()
|
||||
|
||||
def callback(self, payment, request):
|
||||
try:
|
||||
self.handle_ipn(payment, request)
|
||||
return True
|
||||
except IpnError as e:
|
||||
payment.status = 'error'
|
||||
payment.status_message = ("Error processing the payment. "
|
||||
"Please contact support.")
|
||||
payment.backend_data['ipn_exception'] = repr(e)
|
||||
payment.backend_data['ipn_last_data'] = repr(request.POST)
|
||||
payment.save()
|
||||
logger.warn("IPN error: %s", e)
|
||||
raise
|
||||
|
||||
|
@ -1,252 +0,0 @@
|
||||
from django.shortcuts import redirect
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import urlopen
|
||||
from django.urls import reverse
|
||||
from django.conf import settings as project_settings
|
||||
import requests
|
||||
|
||||
from .base import BackendBase
|
||||
|
||||
|
||||
def urljoin(a, b):
|
||||
if b.startswith('/') and a.endswith('/'):
|
||||
return a + b[1:]
|
||||
if b.startswith('/') or a.endswith('/'):
|
||||
return a + b
|
||||
return a + "/" + b
|
||||
|
||||
|
||||
class PaypalBackend(BackendBase):
|
||||
backend_id = 'paypal'
|
||||
backend_verbose_name = _("PayPal")
|
||||
backend_display_name = _("PayPal")
|
||||
backend_has_recurring = True
|
||||
|
||||
def __init__(self, settings):
|
||||
self.test = settings.get('test', False)
|
||||
self.header_image = settings.get('header_image', None)
|
||||
self.title = settings.get('title', 'VPN Payment')
|
||||
self.currency = settings.get('currency', 'EUR')
|
||||
self.account_address = settings.get('address')
|
||||
self.receiver_address = settings.get('receiver', self.account_address)
|
||||
|
||||
self.api_username = settings.get('api_username')
|
||||
self.api_password = settings.get('api_password')
|
||||
self.api_sig = settings.get('api_sig')
|
||||
|
||||
if self.test:
|
||||
default_nvp = 'https://api-3t.sandbox.paypal.com/nvp'
|
||||
default_api = 'https://www.sandbox.paypal.com/'
|
||||
else:
|
||||
default_nvp = 'https://api-3t.paypal.com/nvp'
|
||||
default_api = 'https://www.paypal.com/'
|
||||
self.api_base = settings.get('api_base', default_api)
|
||||
self.nvp_api_base = settings.get('nvp_api_base', default_nvp)
|
||||
|
||||
if self.account_address and self.api_username and self.api_password and self.api_sig:
|
||||
self.backend_enabled = True
|
||||
|
||||
def new_payment(self, payment):
|
||||
ROOT_URL = project_settings.ROOT_URL
|
||||
params = {
|
||||
'cmd': '_xclick',
|
||||
'notify_url': ROOT_URL + reverse('payments:cb_paypal', args=(payment.id,)),
|
||||
'item_name': self.title,
|
||||
'amount': '%.2f' % (payment.amount / 100),
|
||||
'currency_code': self.currency,
|
||||
'business': self.account_address,
|
||||
'no_shipping': '1',
|
||||
'return': ROOT_URL + reverse('payments:view', args=(payment.id,)),
|
||||
'cancel_return': ROOT_URL + reverse('payments:cancel', args=(payment.id,)),
|
||||
}
|
||||
|
||||
if self.header_image:
|
||||
params['cpp_header_image'] = self.header_image
|
||||
|
||||
payment.status_message = _("Waiting for PayPal to confirm the transaction... " +
|
||||
"It can take up to a few minutes...")
|
||||
payment.save()
|
||||
|
||||
return redirect(urljoin(self.api_base, '/cgi-bin/webscr?' + urlencode(params)))
|
||||
|
||||
def new_subscription(self, rps):
|
||||
months = {
|
||||
'3m': 3,
|
||||
'6m': 6,
|
||||
'12m': 12,
|
||||
}[rps.period]
|
||||
|
||||
ROOT_URL = project_settings.ROOT_URL
|
||||
params = {
|
||||
'cmd': '_xclick-subscriptions',
|
||||
'notify_url': ROOT_URL + reverse('payments:cb_paypal_subscr', args=(rps.id,)),
|
||||
'item_name': self.title,
|
||||
'currency_code': self.currency,
|
||||
'business': self.account_address,
|
||||
'no_shipping': '1',
|
||||
'return': ROOT_URL + reverse('payments:return_subscr', args=(rps.id,)),
|
||||
'cancel_return': ROOT_URL + reverse('account:index'),
|
||||
|
||||
'a3': '%.2f' % (rps.period_amount / 100),
|
||||
'p3': str(months),
|
||||
't3': 'M',
|
||||
'src': '1',
|
||||
}
|
||||
|
||||
if self.header_image:
|
||||
params['cpp_header_image'] = self.header_image
|
||||
|
||||
rps.save()
|
||||
|
||||
return redirect(urljoin(self.api_base, '/cgi-bin/webscr?' + urlencode(params)))
|
||||
|
||||
def handle_verified_callback(self, payment, params):
|
||||
if self.test and params['test_ipn'] != '1':
|
||||
raise ValueError('Test IPN')
|
||||
|
||||
txn_type = params.get('txn_type')
|
||||
if txn_type not in (None, 'web_accept', 'express_checkout'):
|
||||
# Not handled here and can be ignored
|
||||
return
|
||||
|
||||
if params['payment_status'] == 'Refunded':
|
||||
payment.status = 'refunded'
|
||||
payment.status_message = None
|
||||
|
||||
elif params['payment_status'] == 'Completed':
|
||||
self.handle_completed_payment(payment, params)
|
||||
|
||||
def handle_verified_callback_subscr(self, subscr, params):
|
||||
if self.test and params['test_ipn'] != '1':
|
||||
raise ValueError('Test IPN')
|
||||
|
||||
txn_type = params.get('txn_type')
|
||||
if not txn_type.startswith('subscr_'):
|
||||
# Not handled here and can be ignored
|
||||
return
|
||||
|
||||
if txn_type == 'subscr_payment':
|
||||
if params['payment_status'] == 'Refunded':
|
||||
# FIXME: Find the payment and do something
|
||||
pass
|
||||
|
||||
elif params['payment_status'] == 'Completed':
|
||||
payment = subscr.create_payment()
|
||||
if not self.handle_completed_payment(payment, params):
|
||||
return
|
||||
|
||||
subscr.last_confirmed_payment = payment.created
|
||||
subscr.backend_extid = params.get('subscr_id', '')
|
||||
if subscr.status == 'new' or subscr.status == 'unconfirmed':
|
||||
subscr.status = 'active'
|
||||
subscr.save()
|
||||
elif txn_type == 'subscr_cancel' or txn_type == 'subscr_eot':
|
||||
subscr.status = 'cancelled'
|
||||
subscr.save()
|
||||
|
||||
def handle_completed_payment(self, payment, params):
|
||||
from payments.models import Payment
|
||||
|
||||
# Prevent making duplicate Payments if IPN is received twice
|
||||
pc = Payment.objects.filter(backend_extid=params['txn_id']).count()
|
||||
if pc > 0:
|
||||
return False
|
||||
|
||||
if self.receiver_address != params['receiver_email']:
|
||||
raise ValueError('Wrong receiver: ' + params['receiver_email'])
|
||||
if self.currency.lower() != params['mc_currency'].lower():
|
||||
raise ValueError('Wrong currency: ' + params['mc_currency'])
|
||||
|
||||
payment.paid_amount = int(float(params['mc_gross']) * 100)
|
||||
if payment.paid_amount < payment.amount:
|
||||
raise ValueError('Not fully paid.')
|
||||
|
||||
payment.user.vpnuser.add_paid_time(payment.time)
|
||||
payment.user.vpnuser.on_payment_confirmed(payment)
|
||||
payment.user.vpnuser.save()
|
||||
|
||||
payment.backend_extid = params['txn_id']
|
||||
payment.status = 'confirmed'
|
||||
payment.status_message = None
|
||||
payment.save()
|
||||
|
||||
payment.user.vpnuser.lcore_sync()
|
||||
return True
|
||||
|
||||
def verify_ipn(self, request):
|
||||
v_url = urljoin(self.api_base, '/cgi-bin/webscr?cmd=_notify-validate')
|
||||
v_req = urlopen(v_url, data=request.body, timeout=5)
|
||||
v_res = v_req.read()
|
||||
return v_res == b'VERIFIED'
|
||||
|
||||
def callback(self, payment, request):
|
||||
if not self.verify_ipn(request):
|
||||
return False
|
||||
|
||||
params = request.POST
|
||||
|
||||
try:
|
||||
self.handle_verified_callback(payment, params)
|
||||
return True
|
||||
except (KeyError, ValueError) as e:
|
||||
payment.status = 'error'
|
||||
payment.status_message = None
|
||||
payment.backend_data['ipn_exception'] = repr(e)
|
||||
payment.backend_data['ipn_last_data'] = repr(request.POST)
|
||||
payment.save()
|
||||
raise
|
||||
|
||||
def callback_subscr(self, subscr, request):
|
||||
if not self.verify_ipn(request):
|
||||
return False
|
||||
|
||||
params = request.POST
|
||||
|
||||
try:
|
||||
self.handle_verified_callback_subscr(subscr, params)
|
||||
return True
|
||||
except (KeyError, ValueError) as e:
|
||||
subscr.status = 'error'
|
||||
subscr.status_message = None
|
||||
subscr.backend_data['ipn_exception'] = repr(e)
|
||||
subscr.backend_data['ipn_last_data'] = repr(request.POST)
|
||||
subscr.save()
|
||||
raise
|
||||
|
||||
def cancel_subscription(self, subscr):
|
||||
if not subscr.backend_extid:
|
||||
return False
|
||||
|
||||
try:
|
||||
r = requests.post(self.nvp_api_base, data={
|
||||
"METHOD": "ManageRecurringPaymentsProfileStatus",
|
||||
"PROFILEID": subscr.backend_extid,
|
||||
"ACTION": "cancel",
|
||||
"USER": self.api_username,
|
||||
"PWD": self.api_password,
|
||||
"SIGNATURE": self.api_sig,
|
||||
"VERSION": "204.0",
|
||||
})
|
||||
r.raise_for_status()
|
||||
print(r.text)
|
||||
|
||||
subscr.status = 'cancelled'
|
||||
subscr.save()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return False
|
||||
|
||||
def get_ext_url(self, payment):
|
||||
if not payment.backend_extid:
|
||||
return None
|
||||
url = 'https://history.paypal.com/webscr?cmd=_history-details-from-hub&id=%s'
|
||||
return url % payment.backend_extid
|
||||
|
||||
def get_subscr_ext_url(self, subscr):
|
||||
if not subscr.backend_extid:
|
||||
return None
|
||||
return ('https://www.paypal.com/fr/cgi-bin/webscr?cmd=_profile-recurring-payments'
|
||||
'&encrypted_profile_id=%s' % subscr.backend_extid)
|
||||
|
@ -1,304 +0,0 @@
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.urls import reverse
|
||||
from django.conf import settings as project_settings
|
||||
|
||||
from .base import BackendBase
|
||||
|
||||
|
||||
class StripeBackend(BackendBase):
|
||||
backend_id = 'stripe'
|
||||
backend_verbose_name = _("Stripe")
|
||||
backend_display_name = _("Credit Card")
|
||||
backend_has_recurring = True
|
||||
|
||||
def get_plan_id(self, period):
|
||||
return 'ccvpn_' + period
|
||||
|
||||
def __init__(self, settings):
|
||||
self.public_key = settings.get('public_key')
|
||||
self.secret_key = settings.get('secret_key')
|
||||
self.wh_key = settings.get('wh_key')
|
||||
|
||||
if not self.public_key or not self.secret_key or not self.wh_key:
|
||||
raise Exception("Missing keys for stripe backend")
|
||||
|
||||
import stripe
|
||||
self.stripe = stripe
|
||||
stripe.api_key = self.secret_key
|
||||
|
||||
self.header_image = settings.get('header_image', '')
|
||||
self.currency = settings.get('currency', 'EUR')
|
||||
self.title = settings.get('title', 'VPN Payment')
|
||||
self.backend_enabled = True
|
||||
|
||||
def make_redirect(self, session):
|
||||
return '''
|
||||
<script src="https://js.stripe.com/v3/"></script>
|
||||
<script type="text/javascript">
|
||||
document.write("<p>Redirecting to the payment page...</p>");
|
||||
var stripe = Stripe("{pk}");
|
||||
stripe.redirectToCheckout({{
|
||||
sessionId: "{sess}"
|
||||
}});
|
||||
</script>
|
||||
<noscript><p>Please enable JavaScript to use the payment form.</p></noscript>
|
||||
'''.format(pk=self.public_key, sess=session['id'])
|
||||
|
||||
def new_payment(self, payment):
|
||||
root_url = project_settings.ROOT_URL
|
||||
assert root_url
|
||||
|
||||
months = payment.time.days // 30
|
||||
if months > 1:
|
||||
desc = '{} months for {}'.format(months, payment.user.username)
|
||||
else:
|
||||
desc = 'One month for {}'.format(payment.user.username)
|
||||
|
||||
session = self.stripe.checkout.Session.create(
|
||||
success_url=root_url + reverse('payments:view', args=(payment.id,)),
|
||||
cancel_url=root_url + reverse('payments:cancel', args=(payment.id,)),
|
||||
payment_method_types=['card'],
|
||||
line_items=[
|
||||
{
|
||||
'amount': payment.amount,
|
||||
'currency': self.currency.lower(),
|
||||
'name': self.title,
|
||||
'description': desc,
|
||||
'quantity': 1,
|
||||
}
|
||||
],
|
||||
)
|
||||
payment.backend_extid = session['id']
|
||||
payment.backend_data = {'session_id': session['id']}
|
||||
payment.save()
|
||||
return self.make_redirect(session)
|
||||
|
||||
def new_subscription(self, subscr):
|
||||
root_url = project_settings.ROOT_URL
|
||||
assert root_url
|
||||
|
||||
session = self.stripe.checkout.Session.create(
|
||||
success_url=root_url + reverse('payments:return_subscr', args=(subscr.id,)),
|
||||
cancel_url=root_url + reverse('payments:cancel_subscr', args=(subscr.id,)),
|
||||
client_reference_id='sub_%d'%subscr.id,
|
||||
payment_method_types=['card'],
|
||||
subscription_data={
|
||||
'items': [{
|
||||
'plan': self.get_plan_id(subscr.period),
|
||||
'quantity': 1,
|
||||
}],
|
||||
},
|
||||
)
|
||||
subscr.backend_data = {'session_id': session['id']}
|
||||
subscr.save()
|
||||
return self.make_redirect(session)
|
||||
|
||||
def cancel_subscription(self, subscr):
|
||||
if subscr.status not in ('new', 'unconfirmed', 'active'):
|
||||
return
|
||||
|
||||
if subscr.backend_extid.startswith('pi'):
|
||||
# a session that didn't create a subscription yet (intent)
|
||||
pass
|
||||
elif subscr.backend_extid.startswith('sub_'):
|
||||
# Subscription object
|
||||
try:
|
||||
self.stripe.Subscription.delete(subscr.backend_extid)
|
||||
except self.stripe.error.InvalidRequestError:
|
||||
pass
|
||||
elif subscr.backend_extid.startswith('cus_'):
|
||||
# Legacy Customer object
|
||||
try:
|
||||
cust = self.stripe.Customer.retrieve(subscr.backend_extid)
|
||||
except self.stripe.error.InvalidRequestError:
|
||||
return
|
||||
|
||||
try:
|
||||
# Delete customer and cancel any active subscription
|
||||
cust.delete()
|
||||
except self.stripe.error.InvalidRequestError:
|
||||
pass
|
||||
else:
|
||||
raise Exception("Failed to cancel subscription %r" % subscr.backend_extid)
|
||||
|
||||
subscr.status = 'cancelled'
|
||||
subscr.save()
|
||||
return True
|
||||
|
||||
def refresh_subscription(self, subscr):
|
||||
if subscr.backend_extid.startswith('cus_'):
|
||||
customer = self.stripe.Customer.retrieve(subscr.backend_extid)
|
||||
for s in customer['subscriptions']['data']:
|
||||
if s['status'] == 'active':
|
||||
sub = s
|
||||
break
|
||||
else:
|
||||
return
|
||||
elif subscr.backend_extid.startswith('sub_'):
|
||||
sub = self.stripe.Subscription.retrieve(subscr.backend_extid)
|
||||
else:
|
||||
print("unhandled subscription backend extid: {}".format(subscr.backend_extid))
|
||||
return
|
||||
|
||||
if sub['status'] == 'canceled':
|
||||
subscr.status = 'cancelled'
|
||||
if sub['status'] == 'past_due':
|
||||
subscr.status = 'error'
|
||||
|
||||
def webhook_session_completed(self, event):
|
||||
session = event['data']['object']
|
||||
|
||||
if session['subscription']:
|
||||
# Subscription creation
|
||||
from payments.models import Payment, Subscription
|
||||
|
||||
sub_id = session['subscription']
|
||||
assert sub_id
|
||||
|
||||
parts = session['client_reference_id'].split('_')
|
||||
if len(parts) != 2 or parts[0] != 'sub':
|
||||
raise Exception("invalid reference id")
|
||||
sub_internal_id = int(parts[1])
|
||||
|
||||
# Fetch sub by ID and confirm it
|
||||
subscr = Subscription.objects.get(id=sub_internal_id)
|
||||
subscr.status = 'active'
|
||||
subscr.backend_extid = sub_id
|
||||
subscr.set_data('subscription_id', sub_id)
|
||||
subscr.save()
|
||||
|
||||
else:
|
||||
from payments.models import Payment
|
||||
payment = Payment.objects.filter(backend_extid=session['id']).get()
|
||||
|
||||
# the amount is provided server-side, we do not have to check
|
||||
payment.paid_amount = payment.amount
|
||||
|
||||
payment.status = 'confirmed'
|
||||
payment.status_message = None
|
||||
payment.save()
|
||||
payment.user.vpnuser.add_paid_time(payment.time)
|
||||
payment.user.vpnuser.on_payment_confirmed(payment)
|
||||
payment.user.vpnuser.save()
|
||||
|
||||
payment.user.vpnuser.lcore_sync()
|
||||
|
||||
def get_subscription_from_invoice(self, invoice):
|
||||
from payments.models import Subscription
|
||||
|
||||
subscription_id = invoice['subscription']
|
||||
customer_id = invoice['customer']
|
||||
|
||||
# once it's confirmed, the id to the subscription is stored as extid
|
||||
subscr = Subscription.objects.filter(backend_extid=subscription_id).first()
|
||||
if subscr:
|
||||
return subscr
|
||||
|
||||
# older subscriptions will have a customer id instead
|
||||
subscr = Subscription.objects.filter(backend_extid=customer_id).first()
|
||||
if subscr:
|
||||
return subscr
|
||||
|
||||
return None
|
||||
|
||||
def webhook_payment_succeeded(self, event):
|
||||
""" webhook event for a subscription's succeeded payment """
|
||||
from payments.models import Payment
|
||||
|
||||
invoice = event['data']['object']
|
||||
subscr = self.get_subscription_from_invoice(invoice)
|
||||
if not subscr:
|
||||
# the subscription does not exist
|
||||
# checkout.confirmed event will create it and handle the initial payment
|
||||
# return True
|
||||
raise Exception("Unknown subscription for invoice %r" % invoice['id'])
|
||||
|
||||
# Prevent making duplicate Payments if event is received twice
|
||||
pc = Payment.objects.filter(backend_extid=invoice['id']).count()
|
||||
if pc > 0:
|
||||
return
|
||||
|
||||
payment = subscr.create_payment()
|
||||
payment.status = 'confirmed'
|
||||
payment.paid_amount = payment.amount
|
||||
payment.backend_extid = invoice['id']
|
||||
if invoice['subscription']:
|
||||
if isinstance(invoice['subscription'], str):
|
||||
payment.backend_sub_id = invoice['subscription']
|
||||
else:
|
||||
payment.backend_sub_id = invoice['subscription']['id']
|
||||
payment.set_data('event_id', event['id'])
|
||||
payment.set_data('sub_id', payment.backend_sub_id)
|
||||
payment.save()
|
||||
|
||||
payment.user.vpnuser.add_paid_time(payment.time)
|
||||
payment.user.vpnuser.on_payment_confirmed(payment)
|
||||
payment.user.vpnuser.save()
|
||||
payment.save()
|
||||
|
||||
payment.user.vpnuser.lcore_sync()
|
||||
|
||||
def webhook_subscr_update(self, event):
|
||||
from payments.models import Subscription
|
||||
stripe_sub = event['data']['object']
|
||||
sub = Subscription.objects.get(backend_id='stripe', backend_extid=stripe_sub['id'])
|
||||
|
||||
if not sub:
|
||||
return
|
||||
|
||||
if stripe_sub['status'] == 'canceled':
|
||||
sub.status = 'cancelled'
|
||||
if stripe_sub['status'] == 'past_due':
|
||||
sub.status = 'error'
|
||||
sub.save()
|
||||
|
||||
def webhook(self, request):
|
||||
payload = request.body
|
||||
sig_header = request.META['HTTP_STRIPE_SIGNATURE']
|
||||
|
||||
try:
|
||||
event = self.stripe.Webhook.construct_event(
|
||||
payload, sig_header, self.wh_key,
|
||||
)
|
||||
except (ValueError, self.stripe.error.InvalidRequestError, self.stripe.error.SignatureVerificationError):
|
||||
return False
|
||||
|
||||
if event['type'] == 'invoice.payment_succeeded':
|
||||
self.webhook_payment_succeeded(event)
|
||||
if event['type'] == 'checkout.session.completed':
|
||||
self.webhook_session_completed(event)
|
||||
if event['type'] == 'customer.subscription.deleted':
|
||||
self.webhook_subscr_update(event)
|
||||
return True
|
||||
|
||||
def get_ext_url(self, payment):
|
||||
extid = payment.backend_extid
|
||||
|
||||
if not extid:
|
||||
return None
|
||||
|
||||
if extid.startswith('in_'):
|
||||
return 'https://dashboard.stripe.com/invoices/%s' % extid
|
||||
if extid.startswith('ch_'):
|
||||
return 'https://dashboard.stripe.com/payments/%s' % extid
|
||||
|
||||
def get_subscr_ext_url(self, subscr):
|
||||
extid = subscr.backend_extid
|
||||
|
||||
if not extid:
|
||||
return None
|
||||
|
||||
if extid.startswith('sub_') and self.stripe:
|
||||
livemode = False
|
||||
try:
|
||||
sub = self.stripe.Subscription.retrieve(extid)
|
||||
livemode = sub['livemode']
|
||||
except Exception:
|
||||
pass
|
||||
if livemode:
|
||||
return 'https://dashboard.stripe.com/subscriptions/' + extid
|
||||
else:
|
||||
return 'https://dashboard.stripe.com/test/subscriptions/' + extid
|
||||
|
||||
if extid.startswith('cus_'):
|
||||
return 'https://dashboard.stripe.com/customers/%s' % subscr.backend_extid
|
@ -1,16 +0,0 @@
|
||||
from django import forms
|
||||
from .models import BACKEND_CHOICES
|
||||
|
||||
|
||||
class NewPaymentForm(forms.Form):
|
||||
TIME_CHOICES = (
|
||||
('1', '1'),
|
||||
('3', '3'),
|
||||
('6', '6'),
|
||||
('12', '12'),
|
||||
)
|
||||
|
||||
subscr = forms.ChoiceField(choices=(('0', 'no'), ('1', 'yes')))
|
||||
time = forms.ChoiceField(choices=TIME_CHOICES)
|
||||
method = forms.ChoiceField(choices=BACKEND_CHOICES)
|
||||
|
@ -1,15 +0,0 @@
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from payments.models import ACTIVE_BACKENDS
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Get bitcoind info"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if 'bitcoin' not in ACTIVE_BACKENDS:
|
||||
raise CommandError("bitcoin backend not active.")
|
||||
|
||||
backend = ACTIVE_BACKENDS['bitcoin']
|
||||
for key, value in backend.get_info():
|
||||
self.stdout.write("%s: %s" % (key, value))
|
@ -1,28 +0,0 @@
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from payments.models import Payment, ACTIVE_BACKENDS
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Check bitcoin payments status"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if 'bitcoin' not in ACTIVE_BACKENDS:
|
||||
raise CommandError("bitcoin backend not active.")
|
||||
|
||||
backend = ACTIVE_BACKENDS['bitcoin']
|
||||
|
||||
payments = Payment.objects.filter(backend_id='bitcoin', status='new')
|
||||
|
||||
self.stdout.write("Found %d active unconfirmed payments." % len(payments))
|
||||
|
||||
for p in payments:
|
||||
self.stdout.write("Checking payment #%d... " % p.id, ending="")
|
||||
backend.check(p)
|
||||
|
||||
if p.status == 'confirmed':
|
||||
self.stdout.write("OK.")
|
||||
else:
|
||||
self.stdout.write("Waiting")
|
||||
|
||||
|
@ -1,52 +0,0 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_duration
|
||||
|
||||
from payments.models import Payment
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Manually confirm a Payment"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('id', action='store', type=int, help="Payment ID")
|
||||
parser.add_argument('--paid-amount', dest='amount', action='store', type=int, help="Paid amount")
|
||||
parser.add_argument('--extid', dest='extid', action='store', type=str)
|
||||
parser.add_argument('-n', dest='sim', action='store_true', help="Simulate")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
try:
|
||||
p = Payment.objects.get(id=options['id'])
|
||||
except Payment.DoesNotExist:
|
||||
self.stderr.write("Cannot find payment #%d" % options['id'])
|
||||
return
|
||||
|
||||
print("Payment #%d by %s (amount=%d; paid_amount=%d)" % (p.id, p.user.username, p.amount, p.paid_amount))
|
||||
|
||||
if options['amount']:
|
||||
pa = options['amount']
|
||||
else:
|
||||
pa = p.amount
|
||||
|
||||
extid = options['extid']
|
||||
|
||||
print("Status -> confirmed")
|
||||
print("Paid amount -> %d" % pa)
|
||||
if extid:
|
||||
print("Ext ID -> %s" % extid)
|
||||
|
||||
print("Confirm? [y/n] ")
|
||||
i = input()
|
||||
if i.lower().strip() == 'y':
|
||||
p.user.vpnuser.add_paid_time(p.time)
|
||||
p.user.vpnuser.on_payment_confirmed(p)
|
||||
p.user.vpnuser.save()
|
||||
|
||||
p.paid_amount = pa
|
||||
p.status = 'confirmed'
|
||||
if extid:
|
||||
p.backend_extid = extid
|
||||
p.save()
|
||||
else:
|
||||
print("aborted.")
|
||||
|
@ -1,31 +0,0 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_duration
|
||||
|
||||
from payments.models import Payment
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Cancels expired Payments"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('-n', dest='sim', action='store_true', help="Simulate")
|
||||
parser.add_argument('-e', '--exp-time', action='store',
|
||||
help="Expiration time.", default='3 00:00:00')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
now = timezone.now()
|
||||
expdate = now - parse_duration(options['exp_time'])
|
||||
|
||||
self.stdout.write("Now: " + now.isoformat())
|
||||
self.stdout.write("Exp: " + expdate.isoformat())
|
||||
|
||||
expired = Payment.objects.filter(created__lte=expdate, status='new',
|
||||
paid_amount=0)
|
||||
|
||||
for p in expired:
|
||||
self.stdout.write("Payment #%d (%s): %s" % (p.id, p.user.username, p.created))
|
||||
if not options['sim']:
|
||||
p.status = 'cancelled'
|
||||
p.save()
|
||||
|
@ -1,70 +0,0 @@
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.conf import settings
|
||||
|
||||
from ccvpn.common import get_price
|
||||
from payments.models import ACTIVE_BACKENDS, SUBSCR_PERIOD_CHOICES, period_months
|
||||
|
||||
CURRENCY_CODE, CURRENCY_NAME = settings.PAYMENTS_CURRENCY
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Update Stripe plans"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--force-run', action='store_true',
|
||||
help="Run even when Stripe backend is disabled")
|
||||
parser.add_argument('--force-update', action='store_true',
|
||||
help="Replace plans, including matching ones")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if 'stripe' not in ACTIVE_BACKENDS and options['force-run'] is False:
|
||||
raise CommandError("stripe backend not active.")
|
||||
|
||||
backend = ACTIVE_BACKENDS['stripe']
|
||||
stripe = backend.stripe
|
||||
|
||||
for period_id, period_name in SUBSCR_PERIOD_CHOICES:
|
||||
plan_id = backend.get_plan_id(period_id)
|
||||
months = period_months(period_id)
|
||||
amount = months * get_price()
|
||||
|
||||
kwargs = dict(
|
||||
id=plan_id,
|
||||
amount=amount,
|
||||
interval='month',
|
||||
interval_count=months,
|
||||
name="VPN Subscription (%s)" % period_id,
|
||||
currency=CURRENCY_CODE,
|
||||
)
|
||||
|
||||
self.stdout.write('Plan %s: %d months for %.2f %s (%s)... ' % (
|
||||
plan_id, months, amount / 100, CURRENCY_NAME, CURRENCY_CODE), ending='')
|
||||
self.stdout.flush()
|
||||
|
||||
try:
|
||||
plan = stripe.Plan.retrieve(plan_id)
|
||||
except stripe.error.InvalidRequestError:
|
||||
plan = None
|
||||
|
||||
def is_valid_plan():
|
||||
if not plan:
|
||||
return False
|
||||
for k, v in kwargs.items():
|
||||
if getattr(plan, k) != v:
|
||||
return False
|
||||
return True
|
||||
|
||||
if plan:
|
||||
if is_valid_plan() and not options['force_update']:
|
||||
self.stdout.write(self.style.SUCCESS('[ok]'))
|
||||
continue
|
||||
plan.delete()
|
||||
update = True
|
||||
else:
|
||||
update = False
|
||||
|
||||
stripe.Plan.create(**kwargs)
|
||||
if update:
|
||||
self.stdout.write(self.style.WARNING('[updated]'))
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING('[created]'))
|
@ -1,60 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import jsonfield.fields
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Payment',
|
||||
fields=[
|
||||
('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)),
|
||||
('backend_id', models.CharField(choices=[('bitcoin', 'Bitcoin'), ('coinbase', 'Coinbase'), ('manual', 'Manual'), ('paypal', 'PayPal'), ('stripe', 'Stripe')], max_length=16)),
|
||||
('status', models.CharField(choices=[('new', 'Waiting for payment'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('rejected', 'Rejected by processor'), ('error', 'Payment processing failed')], max_length=16)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('modified', models.DateTimeField(auto_now=True)),
|
||||
('confirmed_on', models.DateTimeField(null=True, blank=True)),
|
||||
('amount', models.IntegerField()),
|
||||
('paid_amount', models.IntegerField(default=0)),
|
||||
('time', models.DurationField()),
|
||||
('status_message', models.TextField(null=True, blank=True)),
|
||||
('backend_extid', models.CharField(null=True, max_length=64, blank=True)),
|
||||
('backend_data', jsonfield.fields.JSONField(blank=True, default=dict)),
|
||||
],
|
||||
options={
|
||||
'ordering': ('-created',),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RecurringPaymentSource',
|
||||
fields=[
|
||||
('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)),
|
||||
('backend', models.CharField(choices=[('bitcoin', 'Bitcoin'), ('coinbase', 'Coinbase'), ('manual', 'Manual'), ('paypal', 'PayPal'), ('stripe', 'Stripe')], max_length=16)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('modified', models.DateTimeField(auto_now=True)),
|
||||
('period', models.CharField(choices=[('monthly', 'Monthly'), ('biannually', 'Bianually'), ('yearly', 'Yearly')], max_length=16)),
|
||||
('last_confirmed_payment', models.DateTimeField(null=True, blank=True)),
|
||||
('backend_id', models.CharField(null=True, max_length=64, blank=True)),
|
||||
('backend_data', jsonfield.fields.JSONField(blank=True, default=dict)),
|
||||
('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='payment',
|
||||
name='recurring_source',
|
||||
field=models.ForeignKey(null=True, to='payments.RecurringPaymentSource', blank=True, on_delete=models.CASCADE),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='payment',
|
||||
name='user',
|
||||
field=models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.SET_NULL),
|
||||
),
|
||||
]
|
@ -1,19 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('payments', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='recurringpaymentsource',
|
||||
name='period',
|
||||
field=models.CharField(max_length=16, choices=[('6m', 'Every 6 months'), ('1year', 'Yearly')]),
|
||||
),
|
||||
]
|
@ -1,20 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9 on 2015-12-09 04:40
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('payments', '0002_auto_20151204_0341'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='payment',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('new', 'Waiting for payment'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('rejected', 'Rejected by processor'), ('error', 'Payment processing failed')], default='new', max_length=16),
|
||||
),
|
||||
]
|
@ -1,49 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.5 on 2016-09-04 00:48
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import jsonfield.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('payments', '0003_auto_20151209_0440'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Subscription',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('backend_id', models.CharField(choices=[('bitcoin', 'Bitcoin'), ('coinbase', 'Coinbase'), ('manual', 'Manual'), ('paypal', 'PayPal'), ('stripe', 'Stripe')], max_length=16)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('period', models.CharField(choices=[('3m', 'Every 3 months'), ('6m', 'Every 6 months'), ('12m', 'Every year')], max_length=16)),
|
||||
('last_confirmed_payment', models.DateTimeField(blank=True, null=True)),
|
||||
('status', models.CharField(choices=[('new', 'Waiting for payment'), ('active', 'Active'), ('cancelled', 'Cancelled')], default='new', max_length=16)),
|
||||
('backend_extid', models.CharField(blank=True, max_length=64, null=True)),
|
||||
('backend_data', jsonfield.fields.JSONField(blank=True, default=dict)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='recurringpaymentsource',
|
||||
name='user',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='payment',
|
||||
name='recurring_source',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='RecurringPaymentSource',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='payment',
|
||||
name='subscription',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='payments.Subscription'),
|
||||
),
|
||||
]
|
@ -1,20 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.5 on 2016-09-07 00:18
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('payments', '0004_auto_20160904_0048'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='subscription',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('new', 'Created'), ('unconfirmed', 'Waiting for payment'), ('active', 'Active'), ('cancelled', 'Cancelled'), ('error', 'Error')], default='new', max_length=16),
|
||||
),
|
||||
]
|
@ -1,26 +0,0 @@
|
||||
# Generated by Django 2.2.1 on 2019-09-07 20:29
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('payments', '0005_auto_20160907_0018'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Feedback',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('message', models.TextField()),
|
||||
('subscription', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='payments.Subscription')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
@ -1,26 +0,0 @@
|
||||
# Generated by Django 3.1.2 on 2020-11-14 17:30
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('payments', '0006_auto_20190907_2029'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='payment',
|
||||
name='backend_extid',
|
||||
field=models.CharField(blank=True, max_length=256, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='subscription',
|
||||
name='backend_extid',
|
||||
field=models.CharField(blank=True, max_length=256, null=True),
|
||||
),
|
||||
]
|
@ -1,99 +0,0 @@
|
||||
# Generated by Django 3.2.4 on 2021-07-21 19:31
|
||||
|
||||
from datetime import timedelta
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import jsonfield.fields
|
||||
|
||||
def field_to_plan_id(model_name, field_name):
|
||||
def fun(apps, schema_editor):
|
||||
keys = settings.PLANS.keys()
|
||||
model = apps.get_model('payments', model_name)
|
||||
for s in model.objects.all():
|
||||
d = getattr(s, field_name)
|
||||
if s.plan_id is not None:
|
||||
continue
|
||||
s.plan_id = str(round(d / timedelta(days=30))) + 'm'
|
||||
if s.plan_id not in keys:
|
||||
raise Exception(f"unknown plan: {s.plan_id}")
|
||||
s.save()
|
||||
return fun
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('payments', '0007_auto_20201114_1730'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField("Subscription", 'period', 'plan_id'),
|
||||
|
||||
# Add plan_id to payments and convert from the time field
|
||||
migrations.AddField(
|
||||
model_name='payment',
|
||||
name='plan_id',
|
||||
field=models.CharField(choices=[('1m', 'Every 1 month'), ('3m', 'Every 3 months'), ('6m', 'Every 6 months'), ('12m', 'Every 12 months')], max_length=16, null=True),
|
||||
),
|
||||
migrations.RunPython(field_to_plan_id('Payment', 'time'), lambda x, y: ()),
|
||||
|
||||
# Make those two columns non-null once converted
|
||||
migrations.AlterField(
|
||||
model_name='payment',
|
||||
name='plan_id',
|
||||
field=models.CharField(choices=[('1m', 'Every 1 month'), ('3m', 'Every 3 months'), ('6m', 'Every 6 months'), ('12m', 'Every 12 months')], max_length=16),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='subscription',
|
||||
name='plan_id',
|
||||
field=models.CharField(choices=[('1m', 'Every 1 month'), ('3m', 'Every 3 months'), ('6m', 'Every 6 months'), ('12m', 'Every 12 months')], max_length=16),
|
||||
),
|
||||
|
||||
migrations.AddField(
|
||||
model_name='payment',
|
||||
name='ip_address',
|
||||
field=models.GenericIPAddressField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='payment',
|
||||
name='refund_date',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='payment',
|
||||
name='refund_text',
|
||||
field=models.CharField(blank=True, max_length=200),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subscription',
|
||||
name='next_payment_date',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='payment',
|
||||
name='backend_data',
|
||||
field=jsonfield.fields.JSONField(blank=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='payment',
|
||||
name='backend_id',
|
||||
field=models.CharField(choices=[('bitcoin', 'Bitcoin'), ('paypal', 'PayPal'), ('stripe', 'Stripe')], max_length=16),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='payment',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='subscription',
|
||||
name='backend_data',
|
||||
field=jsonfield.fields.JSONField(blank=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='subscription',
|
||||
name='backend_id',
|
||||
field=models.CharField(choices=[('bitcoin', 'Bitcoin'), ('paypal', 'PayPal'), ('stripe', 'Stripe')], max_length=16),
|
||||
),
|
||||
]
|
@ -1,272 +0,0 @@
|
||||
import logging
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from jsonfield import JSONField
|
||||
from datetime import timedelta
|
||||
import json
|
||||
|
||||
from ccvpn.common import get_price
|
||||
from .backends import BackendBase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
backends_settings = settings.PAYMENTS_BACKENDS
|
||||
assert isinstance(backends_settings, dict)
|
||||
|
||||
CURRENCY_CODE, CURRENCY_NAME = settings.PAYMENTS_CURRENCY
|
||||
|
||||
STATUS_CHOICES = (
|
||||
('new', _("Waiting for payment")),
|
||||
('confirmed', _("Confirmed")),
|
||||
('cancelled', _("Cancelled")),
|
||||
('rejected', _("Rejected by processor")),
|
||||
('error', _("Payment processing failed")),
|
||||
)
|
||||
|
||||
# A Subscription is created with status='new'. When getting back from PayPal,
|
||||
# it may get upgraded to 'unconfirmed'. It will be set 'active' with the first
|
||||
# confirmed payment.
|
||||
# 'unconfirmed' exists to prevent creation of a second Subscription while
|
||||
# waiting for the first one to be confirmed.
|
||||
SUBSCR_STATUS_CHOICES = (
|
||||
('new', _("Created")),
|
||||
('unconfirmed', _("Waiting for payment")),
|
||||
('active', _("Active")),
|
||||
('cancelled', _("Cancelled")),
|
||||
('error', _("Error")),
|
||||
)
|
||||
|
||||
SUBSCR_PERIOD_CHOICES = (
|
||||
('3m', _("Every 3 months")),
|
||||
('6m', _("Every 6 months")),
|
||||
('12m', _("Every year")),
|
||||
)
|
||||
|
||||
BACKEND_CLASSES = BackendBase.__subclasses__()
|
||||
|
||||
# All known backends (classes)
|
||||
BACKENDS = {}
|
||||
BACKEND_CHOICES = []
|
||||
|
||||
# All enabled backends (configured instances)
|
||||
ACTIVE_BACKENDS = {}
|
||||
ACTIVE_BACKEND_CHOICES = []
|
||||
|
||||
logger.info("loading payment backends...")
|
||||
for cls in BACKEND_CLASSES:
|
||||
name = cls.backend_id
|
||||
assert isinstance(name, str)
|
||||
|
||||
if name not in backends_settings:
|
||||
logger.info("payments: ☐ %s disabled (no settings)", name)
|
||||
continue
|
||||
|
||||
backend_settings = backends_settings.get(name, {})
|
||||
for k, v in backend_settings.items():
|
||||
if hasattr(v, '__call__'):
|
||||
backend_settings[k] = v()
|
||||
|
||||
if not backend_settings.get('enabled'):
|
||||
logger.info("payments: ☐ %s disabled (by settings)", name)
|
||||
continue
|
||||
|
||||
obj = cls(backend_settings)
|
||||
|
||||
BACKENDS[name] = obj
|
||||
BACKEND_CHOICES.append((name, cls.backend_verbose_name))
|
||||
|
||||
if obj.backend_enabled:
|
||||
ACTIVE_BACKENDS[name] = obj
|
||||
ACTIVE_BACKEND_CHOICES.append((name, cls.backend_verbose_name))
|
||||
logger.info("payments: ☑ %s initialized", name)
|
||||
else:
|
||||
logger.info("payments: ☒ %s disabled (initialization failed)", name)
|
||||
|
||||
BACKEND_CHOICES = sorted(BACKEND_CHOICES, key=lambda x: x[0])
|
||||
ACTIVE_BACKEND_CHOICES = sorted(ACTIVE_BACKEND_CHOICES, key=lambda x: x[0])
|
||||
|
||||
logger.info("payments: finished. %d/%d backends active", len(ACTIVE_BACKENDS), len(BACKEND_CLASSES))
|
||||
|
||||
|
||||
class PlanBase(object):
|
||||
def __init__(self, name, months, monthly, saves="", default=False):
|
||||
self.name = name
|
||||
self.months = months
|
||||
self.monthly = monthly
|
||||
self.saves = saves
|
||||
self.default = default
|
||||
@property
|
||||
def due_amount(self):
|
||||
return round(self.months * self.monthly, 2)
|
||||
@property
|
||||
def time_display(self):
|
||||
return "%d month%s" % (self.months, 's' if self.months > 1 else '')
|
||||
def json(self):
|
||||
return {'total': self.due_amount}
|
||||
|
||||
|
||||
PLANS = {k: PlanBase(name=k, **settings.PLANS[k]) for k, v in settings.PLANS.items()}
|
||||
PLAN_CHOICES = [(k, "Every " + p.time_display) for k, p in PLANS.items()]
|
||||
SUBSCR_PLAN_CHOICES = PLAN_CHOICES
|
||||
|
||||
def period_months(p):
|
||||
return {
|
||||
'3m': 3,
|
||||
'6m': 6,
|
||||
'12m': 12,
|
||||
}[p]
|
||||
|
||||
|
||||
class BackendData:
|
||||
backend_data = None
|
||||
|
||||
def set_data(self, key, value):
|
||||
""" adds a backend data key to this instance's dict """
|
||||
if not self.backend_data:
|
||||
self.backend_data = {}
|
||||
|
||||
if isinstance(self.backend_data, str):
|
||||
self.backend_data = json.loads(self.backend_data) or {}
|
||||
|
||||
if not isinstance(self.backend_data, dict):
|
||||
raise Exception("self.backend_data is not a dict (%r)" % self.backend_data)
|
||||
self.backend_data[key] = value
|
||||
|
||||
|
||||
class Payment(models.Model, BackendData):
|
||||
""" Just a payment.
|
||||
If subscription is not null, it has been automatically issued.
|
||||
backend_extid is the external transaction ID, backend_data is other
|
||||
things that should only be used by the associated backend.
|
||||
"""
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||
backend_id = models.CharField(max_length=16, choices=BACKEND_CHOICES)
|
||||
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default='new')
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
modified = models.DateTimeField(auto_now=True)
|
||||
confirmed_on = models.DateTimeField(null=True, blank=True)
|
||||
amount = models.IntegerField()
|
||||
paid_amount = models.IntegerField(default=0)
|
||||
time = models.DurationField()
|
||||
plan_id = models.CharField(max_length=16, choices=SUBSCR_PLAN_CHOICES)
|
||||
subscription = models.ForeignKey('Subscription', null=True, blank=True, on_delete=models.CASCADE)
|
||||
status_message = models.TextField(blank=True, null=True)
|
||||
ip_address = models.GenericIPAddressField(blank=True, null=True)
|
||||
|
||||
backend_extid = models.CharField(max_length=256, null=True, blank=True)
|
||||
backend_data = JSONField(blank=True)
|
||||
|
||||
refund_date = models.DateTimeField(blank=True, null=True)
|
||||
refund_text = models.CharField(max_length=200, blank=True)
|
||||
|
||||
@property
|
||||
def currency_code(self):
|
||||
return CURRENCY_CODE
|
||||
|
||||
@property
|
||||
def currency_name(self):
|
||||
return CURRENCY_NAME
|
||||
|
||||
@property
|
||||
def backend(self):
|
||||
""" Returns a global instance of the backend
|
||||
:rtype: BackendBase
|
||||
"""
|
||||
return BACKENDS[self.backend_id]
|
||||
|
||||
def get_amount_display(self):
|
||||
return '%.2f %s' % (self.amount / 100, CURRENCY_NAME)
|
||||
|
||||
@property
|
||||
def is_confirmed(self):
|
||||
return self.status == 'confirmed'
|
||||
|
||||
def confirm(self):
|
||||
self.user.vpnuser.add_paid_time(self.time)
|
||||
self.user.vpnuser.on_payment_confirmed(self)
|
||||
self.user.vpnuser.save()
|
||||
self.update_status('confirmed')
|
||||
|
||||
def refund(self):
|
||||
self.user.vpnuser.remove_paid_time(self.time)
|
||||
self.user.vpnuser.save()
|
||||
self.update_status('refunded')
|
||||
|
||||
def update_status(self, status, message=None):
|
||||
assert any(c[0] == status for c in STATUS_CHOICES)
|
||||
self.status = status
|
||||
self.status_message = message
|
||||
|
||||
class Meta:
|
||||
ordering = ('-created', )
|
||||
|
||||
@classmethod
|
||||
def create_payment(self, backend_id, user, months):
|
||||
payment = Payment(
|
||||
user=user,
|
||||
backend_id=backend_id,
|
||||
status='new',
|
||||
time=timedelta(days=30 * months),
|
||||
amount=get_price() * months
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
class Subscription(models.Model, BackendData):
|
||||
""" Recurring payment subscription. """
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||
backend_id = models.CharField(max_length=16, choices=BACKEND_CHOICES)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
plan_id = models.CharField(max_length=16, choices=SUBSCR_PLAN_CHOICES)
|
||||
next_payment_date = models.DateTimeField(blank=True, null=True)
|
||||
last_confirmed_payment = models.DateTimeField(blank=True, null=True)
|
||||
status = models.CharField(max_length=16, choices=SUBSCR_STATUS_CHOICES, default='new')
|
||||
|
||||
backend_extid = models.CharField(max_length=256, null=True, blank=True)
|
||||
backend_data = JSONField(blank=True)
|
||||
|
||||
@property
|
||||
def backend(self):
|
||||
""" Returns a global instance of the backend
|
||||
:rtype: BackendBase
|
||||
"""
|
||||
return BACKENDS[self.backend_id]
|
||||
|
||||
@property
|
||||
def months(self):
|
||||
return period_months(self.period)
|
||||
|
||||
@property
|
||||
def period_amount(self):
|
||||
return self.months * get_price()
|
||||
|
||||
@property
|
||||
def next_renew(self):
|
||||
""" Approximate date of the next payment """
|
||||
if self.last_confirmed_payment:
|
||||
return self.last_confirmed_payment + timedelta(days=self.months * 30)
|
||||
return self.created + timedelta(days=self.months * 30)
|
||||
|
||||
@property
|
||||
def monthly_amount(self):
|
||||
return get_price()
|
||||
|
||||
def create_payment(self):
|
||||
payment = Payment(
|
||||
user=self.user,
|
||||
backend_id=self.backend_id,
|
||||
status='new',
|
||||
time=timedelta(days=30 * self.months),
|
||||
amount=get_price() * self.months,
|
||||
subscription=self,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
class Feedback(models.Model):
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||
subscription = models.ForeignKey('Subscription', null=True, blank=True, on_delete=models.SET_NULL)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
message = models.TextField()
|
||||
|
@ -1,33 +0,0 @@
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone
|
||||
from celery import task
|
||||
|
||||
from payments.models import Payment
|
||||
from .models import Payment, Subscription, ACTIVE_BACKENDS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@task
|
||||
def check_subscriptions():
|
||||
logger.debug("checking subscriptions")
|
||||
subs = Subscription.objects.filter(status='active', backend_id='stripe').all()
|
||||
for sub in subs:
|
||||
logger.debug("checking subscription #%s on %s", sub.id, sub.backend_id)
|
||||
sub.refresh_from_db()
|
||||
ACTIVE_BACKENDS['stripe'].refresh_subscription(sub)
|
||||
sub.save()
|
||||
|
||||
@task
|
||||
def cancel_old_payments():
|
||||
expdate = timezone.now() - timedelta(days=3)
|
||||
|
||||
expired = Payment.objects.filter(created__lte=expdate, status='new',
|
||||
paid_amount=0)
|
||||
|
||||
logger.info("cancelling %d pending payments older than 3 days (%s)", len(expired), expdate.isoformat())
|
||||
|
||||
for p in expired:
|
||||
logger.debug("cancelling payment #%d (%s): created on %s", p.id, p.user.username, p.created)
|
||||
p.status = 'cancelled'
|
||||
p.save()
|
@ -1,10 +0,0 @@
|
||||
# flake8: noqa
|
||||
|
||||
from .bitcoin import *
|
||||
from .paypal import *
|
||||
from .coingate import *
|
||||
|
||||
from django.conf import settings
|
||||
if settings.RUN_ONLINE_TESTS:
|
||||
from .online.stripe import *
|
||||
|
@ -1,118 +0,0 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.test import TestCase
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.contrib.auth.models import User
|
||||
from constance.test import override_config
|
||||
|
||||
from payments.models import Payment
|
||||
from payments.backends import BitcoinBackend
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
class FakeBTCRPCNew:
|
||||
def getnewaddress(self, account):
|
||||
return 'TEST_ADDRESS'
|
||||
|
||||
|
||||
class FakeBTCRPCUnpaid:
|
||||
def getreceivedbyaddress(self, address):
|
||||
assert address == 'TEST_ADDRESS'
|
||||
return Decimal('0')
|
||||
|
||||
|
||||
class FakeBTCRPCPartial:
|
||||
def getreceivedbyaddress(self, address):
|
||||
assert address == 'TEST_ADDRESS'
|
||||
return Decimal('0.5') * 100000000
|
||||
|
||||
|
||||
class FakeBTCRPCPaid:
|
||||
def getreceivedbyaddress(self, address):
|
||||
assert address == 'TEST_ADDRESS'
|
||||
return Decimal('1') * 100000000
|
||||
|
||||
|
||||
class BitcoinBackendTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user('test', 'test_user@example.com', None)
|
||||
|
||||
self.p = Payment.objects.create(
|
||||
user=self.user, time=timedelta(days=30), backend_id='bitcoin',
|
||||
amount=300)
|
||||
|
||||
@override_config(BTC_EUR_VALUE=300)
|
||||
def test_new(self):
|
||||
backend = BitcoinBackend(dict(URL=''))
|
||||
backend.make_rpc = FakeBTCRPCNew
|
||||
|
||||
backend.new_payment(self.p)
|
||||
redirect = backend.new_payment(self.p)
|
||||
self.assertEqual(self.p.backend_extid, 'TEST_ADDRESS')
|
||||
self.assertEqual(self.p.status, 'new')
|
||||
self.assertIn('btc_price', self.p.backend_data)
|
||||
self.assertIn('btc_address', self.p.backend_data)
|
||||
self.assertEqual(self.p.backend_data['btc_address'], 'TEST_ADDRESS')
|
||||
self.assertIsInstance(redirect, HttpResponseRedirect)
|
||||
self.assertEqual(redirect.url, '/payments/view/%d' % self.p.id)
|
||||
self.assertEqual(self.p.status_message, "Please send 1.00000 BTC to TEST_ADDRESS")
|
||||
|
||||
def test_rounding(self):
|
||||
""" Rounding test
|
||||
300 / 300 = 1 => 1.00000 BTC
|
||||
300 / 260 = Decimal('1.153846153846153846153846154') => 1.15385 BTC
|
||||
"""
|
||||
with override_config(BTC_EUR_VALUE=300):
|
||||
backend = BitcoinBackend(dict(URL=''))
|
||||
backend.make_rpc = FakeBTCRPCNew
|
||||
backend.new_payment(self.p)
|
||||
self.assertEqual(self.p.status_message, "Please send 1.00000 BTC to TEST_ADDRESS")
|
||||
|
||||
with override_config(BTC_EUR_VALUE=260):
|
||||
backend = BitcoinBackend(dict(URL=''))
|
||||
backend.make_rpc = FakeBTCRPCNew
|
||||
backend.new_payment(self.p)
|
||||
self.assertEqual(self.p.status_message, "Please send 1.15385 BTC to TEST_ADDRESS")
|
||||
|
||||
|
||||
class BitcoinBackendConfirmTest(TestCase):
|
||||
@override_config(BTC_EUR_VALUE=300)
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user('test', 'test_user@example.com', None)
|
||||
|
||||
self.p = Payment.objects.create(
|
||||
user=self.user, time=timedelta(days=30), backend_id='bitcoin',
|
||||
amount=300)
|
||||
|
||||
# call new_payment
|
||||
backend = BitcoinBackend(dict(URL=''))
|
||||
backend.make_rpc = FakeBTCRPCNew
|
||||
backend.new_payment(self.p)
|
||||
|
||||
@override_config(BTC_EUR_VALUE=300)
|
||||
def test_check_unpaid(self):
|
||||
backend = BitcoinBackend(dict(URL=''))
|
||||
backend.make_rpc = FakeBTCRPCUnpaid
|
||||
|
||||
backend.check(self.p)
|
||||
self.assertEqual(self.p.status, 'new')
|
||||
self.assertEqual(self.p.paid_amount, 0)
|
||||
|
||||
@override_config(BTC_EUR_VALUE=300)
|
||||
def test_check_partially_paid(self):
|
||||
backend = BitcoinBackend(dict(URL=''))
|
||||
backend.make_rpc = FakeBTCRPCPartial
|
||||
backend.check(self.p)
|
||||
self.assertEqual(self.p.status, 'new')
|
||||
self.assertEqual(self.p.paid_amount, 150)
|
||||
|
||||
@override_config(BTC_EUR_VALUE=300)
|
||||
def test_check_paid(self):
|
||||
backend = BitcoinBackend(dict(URL=''))
|
||||
backend.make_rpc = FakeBTCRPCPaid
|
||||
backend.check(self.p)
|
||||
self.assertEqual(self.p.paid_amount, 300)
|
||||
self.assertEqual(self.p.status, 'confirmed')
|
||||
|
||||
|
@ -1,75 +0,0 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.test import TestCase, RequestFactory
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from payments.models import Payment
|
||||
from payments.backends import CoinGateBackend
|
||||
|
||||
|
||||
class CoinGateBackendTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user('test', 'test_user@example.com', None)
|
||||
|
||||
self.backend_settings = dict(
|
||||
api_token='test',
|
||||
title='Test Title',
|
||||
currency='EUR',
|
||||
)
|
||||
|
||||
def test_payment(self):
|
||||
payment = Payment.objects.create(
|
||||
user=self.user,
|
||||
time=timedelta(days=30),
|
||||
backend_id='coingate',
|
||||
amount=300
|
||||
)
|
||||
|
||||
def fake_post(_backend, *, data={}):
|
||||
self.assertEqual(data['order_id'], '1')
|
||||
self.assertEqual(data['price_amount'], 3.0)
|
||||
self.assertEqual(data['price_currency'], 'EUR')
|
||||
self.assertEqual(data['receive_currency'], 'EUR')
|
||||
return {'id': 42, 'payment_url': 'http://testtoken/'}
|
||||
|
||||
with self.settings(ROOT_URL='root'):
|
||||
backend = CoinGateBackend(self.backend_settings)
|
||||
backend._post = fake_post
|
||||
redirect = backend.new_payment(payment)
|
||||
|
||||
self.assertIsInstance(redirect, HttpResponseRedirect)
|
||||
self.assertEqual(redirect.url, 'http://testtoken/')
|
||||
self.assertEqual(payment.backend_data.get('coingate_id'), 42)
|
||||
|
||||
|
||||
# Test a standard successful payment callback flow
|
||||
|
||||
def post_callback(status):
|
||||
callback_data = {
|
||||
'token': payment.backend_data['coingate_token'],
|
||||
'order_id': str(payment.id),
|
||||
'status': status,
|
||||
}
|
||||
ipn_url = '/payments/callback/coingate/%d' % payment.id
|
||||
ipn_request = RequestFactory().post(
|
||||
ipn_url,
|
||||
data=callback_data)
|
||||
return backend.callback(payment, ipn_request)
|
||||
|
||||
r = post_callback('pending')
|
||||
self.assertTrue(r)
|
||||
self.assertEqual(payment.status, 'new')
|
||||
|
||||
r = post_callback('confirming')
|
||||
self.assertTrue(r)
|
||||
self.assertEqual(payment.status, 'new')
|
||||
|
||||
r = post_callback('paid')
|
||||
self.assertTrue(r)
|
||||
self.assertEqual(payment.status, 'confirmed')
|
||||
self.assertEqual(payment.paid_amount, 300)
|
||||
|
||||
time_left_s = self.user.vpnuser.time_left.total_seconds()
|
||||
self.assertAlmostEqual(time_left_s, payment.time.total_seconds(), delta=60)
|
||||
|
@ -1,325 +0,0 @@
|
||||
from datetime import timedelta
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
from django.test import TestCase, RequestFactory
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from payments.models import Payment, Subscription
|
||||
from payments.backends import PaypalBackend
|
||||
|
||||
|
||||
PAYPAL_IPN_TEST = '''\
|
||||
mc_gross=3.00&\
|
||||
protection_eligibility=Eligible&\
|
||||
address_status=confirmed&\
|
||||
payer_id=LPLWNMTBWMFAY&\
|
||||
tax=0.00&\
|
||||
address_street=1+Main+St&\
|
||||
payment_date=20%3A12%3A59+Jan+13%2C+2009+PST&\
|
||||
payment_status=Completed&\
|
||||
charset=windows-1252&\
|
||||
address_zip=95131&\
|
||||
first_name=Test&\
|
||||
mc_fee=0.88&\
|
||||
address_country_code=US&\
|
||||
address_name=Test+User&\
|
||||
notify_version=2.6&\
|
||||
custom=&\
|
||||
payer_status=verified&\
|
||||
address_country=United+States&\
|
||||
address_city=San+Jose&\
|
||||
quantity=1&\
|
||||
payer_email=test_user@example.com&\
|
||||
txn_id=61E67681CH3238416&\
|
||||
payment_type=instant&\
|
||||
last_name=User&\
|
||||
address_state=CA&\
|
||||
receiver_email=test_business@example.com&\
|
||||
payment_fee=0.88&\
|
||||
receiver_id=S8XGHLYDW9T3S&\
|
||||
txn_type=express_checkout&\
|
||||
item_name=&\
|
||||
mc_currency=EUR&\
|
||||
item_number=&\
|
||||
residence_country=US&\
|
||||
test_ipn=1&\
|
||||
handling_amount=0.00&\
|
||||
transaction_subject=&\
|
||||
payment_gross=3.00&\
|
||||
shipping=0.00'''
|
||||
|
||||
PAYPAL_IPN_SUBSCR_PAYMENT = '''\
|
||||
transaction_subject=VPN+Payment&\
|
||||
payment_date=11%3A19%3A00+Sep+04%2C+2016+PDT&\
|
||||
txn_type=subscr_payment&\
|
||||
subscr_id=I-1S262863X133&\
|
||||
last_name=buyer&\
|
||||
residence_country=FR&\
|
||||
item_name=VPN+Payment&\
|
||||
payment_gross=&\
|
||||
mc_currency=EUR&\
|
||||
business=test_business@example.com&\
|
||||
payment_type=instant&\
|
||||
protection_eligibility=Ineligible&\
|
||||
payer_status=verified&\
|
||||
test_ipn=1&\
|
||||
payer_email=test_user@example.com&\
|
||||
txn_id=097872679P963871Y&\
|
||||
receiver_email=test_business@example.com&\
|
||||
first_name=test&\
|
||||
payer_id=APYYVSFLNPWUU&\
|
||||
receiver_id=MGT8TQ8GC4944&\
|
||||
payment_status=Completed&\
|
||||
payment_fee=&\
|
||||
mc_fee=0.56&\
|
||||
mc_gross=9.00&\
|
||||
charset=windows-1252&\
|
||||
notify_version=3.8&\
|
||||
ipn_track_id=546a4aa4300a0'''
|
||||
|
||||
|
||||
PAYPAL_IPN_SUBSCR_CANCEL = '''\
|
||||
txn_type=subscr_cancel&\
|
||||
subscr_id=I-E5SCT6936H40&\
|
||||
last_name=buyer&\
|
||||
residence_country=FR&\
|
||||
mc_currency=EUR&\
|
||||
item_name=VPN+Payment&\
|
||||
business=test_business@example.com&\
|
||||
recurring=1&\
|
||||
payer_status=verified&\
|
||||
test_ipn=1&\
|
||||
payer_email=test_user@example.com&\
|
||||
first_name=test&\
|
||||
receiver_email=test_business@example.com&\
|
||||
payer_id=APYYVSFLNPWUU&\
|
||||
reattempt=1&\
|
||||
subscr_date=17%3A35%3A14+Sep+04%2C+2016+PDT&\
|
||||
charset=windows-1252&\
|
||||
notify_version=3.8&\
|
||||
period3=3+M&\
|
||||
mc_amount3=9.00&\
|
||||
ipn_track_id=474870d13b375'''
|
||||
|
||||
|
||||
PAYPAL_IPN_SUBSCR_SIGNUP = '''\
|
||||
txn_type=subscr_signup&\
|
||||
subscr_id=I-1S262863X133&\
|
||||
last_name=buyer&\
|
||||
residence_country=FR&\
|
||||
mc_currency=EUR&\
|
||||
item_name=VPN+Payment&\
|
||||
business=test_business@example.com&\
|
||||
recurring=1&\
|
||||
payer_status=verified&\
|
||||
test_ipn=1&\
|
||||
payer_email=test_user@example.com&\
|
||||
first_name=test&\
|
||||
receiver_email=test_business@example.com&\
|
||||
payer_id=APYYVSFLNPWUU&\
|
||||
reattempt=1&\
|
||||
subscr_date=11%3A18%3A57+Sep+04%2C+2016+PDT&\
|
||||
charset=windows-1252&\
|
||||
notify_version=3.8&\
|
||||
period3=3+M&\
|
||||
mc_amount3=9.00&\
|
||||
ipn_track_id=546a4aa4300a0'''
|
||||
|
||||
|
||||
class PaypalBackendTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user('test', 'test_user@example.com', None)
|
||||
|
||||
def test_paypal(self):
|
||||
# TODO: This checks the most simple and perfect payment that could
|
||||
# happen, but not errors or other/invalid IPN
|
||||
|
||||
payment = Payment.objects.create(
|
||||
user=self.user,
|
||||
time=timedelta(days=30),
|
||||
backend_id='paypal',
|
||||
amount=300
|
||||
)
|
||||
|
||||
settings = dict(
|
||||
test=True,
|
||||
title='Test Title',
|
||||
currency='EUR',
|
||||
address='test_business@example.com',
|
||||
)
|
||||
|
||||
with self.settings(ROOT_URL='root'):
|
||||
backend = PaypalBackend(settings)
|
||||
redirect = backend.new_payment(payment)
|
||||
|
||||
self.assertIsInstance(redirect, HttpResponseRedirect)
|
||||
|
||||
host, params = redirect.url.split('?', 1)
|
||||
params = parse_qs(params)
|
||||
|
||||
expected_notify_url = 'root/payments/callback/paypal/%d' % payment.id
|
||||
expected_return_url = 'root/payments/view/%d' % payment.id
|
||||
expected_cancel_url = 'root/payments/cancel/%d' % payment.id
|
||||
|
||||
self.assertEqual(params['cmd'][0], '_xclick')
|
||||
self.assertEqual(params['notify_url'][0], expected_notify_url)
|
||||
self.assertEqual(params['return'][0], expected_return_url)
|
||||
self.assertEqual(params['cancel_return'][0], expected_cancel_url)
|
||||
self.assertEqual(params['business'][0], 'test_business@example.com')
|
||||
self.assertEqual(params['currency_code'][0], 'EUR')
|
||||
self.assertEqual(params['amount'][0], '3.00')
|
||||
self.assertEqual(params['item_name'][0], 'Test Title')
|
||||
|
||||
# Replace PaypalBackend.verify_ipn to not call the PayPal API
|
||||
# we will assume the IPN is authentic
|
||||
backend.verify_ipn = lambda request: True
|
||||
|
||||
ipn_url = '/payments/callback/paypal/%d' % payment.id
|
||||
ipn_request = RequestFactory().post(
|
||||
ipn_url,
|
||||
content_type='application/x-www-form-urlencoded',
|
||||
data=PAYPAL_IPN_TEST)
|
||||
r = backend.callback(payment, ipn_request)
|
||||
|
||||
self.assertTrue(r)
|
||||
self.assertEqual(payment.status, 'confirmed')
|
||||
self.assertEqual(payment.paid_amount, 300)
|
||||
self.assertEqual(payment.backend_extid, '61E67681CH3238416')
|
||||
|
||||
def test_paypal_ipn_error(self):
|
||||
payment = Payment.objects.create(
|
||||
user=self.user,
|
||||
time=timedelta(days=30),
|
||||
backend_id='paypal',
|
||||
amount=300
|
||||
)
|
||||
|
||||
settings = dict(
|
||||
test=True,
|
||||
title='Test Title',
|
||||
currency='EUR',
|
||||
address='test_business@example.com',
|
||||
)
|
||||
|
||||
with self.settings(ROOT_URL='root'):
|
||||
backend = PaypalBackend(settings)
|
||||
redirect = backend.new_payment(payment)
|
||||
|
||||
self.assertIsInstance(redirect, HttpResponseRedirect)
|
||||
|
||||
host, params = redirect.url.split('?', 1)
|
||||
params = parse_qs(params)
|
||||
|
||||
# Replace PaypalBackend.verify_ipn to not call the PayPal API
|
||||
# we will assume the IPN is authentic
|
||||
backend.verify_ipn = lambda request: True
|
||||
|
||||
ipn_url = '/payments/callback/paypal/%d' % payment.id
|
||||
ipn_request = RequestFactory().post(
|
||||
ipn_url,
|
||||
content_type='application/x-www-form-urlencoded',
|
||||
data=PAYPAL_IPN_TEST)
|
||||
r = backend.callback(payment, ipn_request)
|
||||
|
||||
self.assertTrue(r)
|
||||
self.assertEqual(payment.status, 'confirmed')
|
||||
self.assertEqual(payment.paid_amount, 300)
|
||||
self.assertEqual(payment.backend_extid, '61E67681CH3238416')
|
||||
|
||||
def test_paypal_subscr(self):
|
||||
subscription = Subscription.objects.create(
|
||||
user=self.user,
|
||||
backend_id='paypal',
|
||||
period='3m'
|
||||
)
|
||||
|
||||
settings = dict(
|
||||
test=True,
|
||||
title='Test Title',
|
||||
currency='EUR',
|
||||
address='test_business@example.com',
|
||||
)
|
||||
|
||||
with self.settings(ROOT_URL='root'):
|
||||
backend = PaypalBackend(settings)
|
||||
redirect = backend.new_subscription(subscription)
|
||||
|
||||
self.assertIsInstance(redirect, HttpResponseRedirect)
|
||||
|
||||
host, params = redirect.url.split('?', 1)
|
||||
params = parse_qs(params)
|
||||
|
||||
expected_notify_url = 'root/payments/callback/paypal_subscr/%d' % subscription.id
|
||||
expected_return_url = 'root/payments/return_subscr/%d' % subscription.id
|
||||
expected_cancel_url = 'root/account/'
|
||||
|
||||
self.assertEqual(params['cmd'][0], '_xclick-subscriptions')
|
||||
self.assertEqual(params['notify_url'][0], expected_notify_url)
|
||||
self.assertEqual(params['return'][0], expected_return_url)
|
||||
self.assertEqual(params['cancel_return'][0], expected_cancel_url)
|
||||
self.assertEqual(params['business'][0], 'test_business@example.com')
|
||||
self.assertEqual(params['currency_code'][0], 'EUR')
|
||||
self.assertEqual(params['a3'][0], '9.00')
|
||||
self.assertEqual(params['p3'][0], '3')
|
||||
self.assertEqual(params['t3'][0], 'M')
|
||||
self.assertEqual(params['item_name'][0], 'Test Title')
|
||||
|
||||
# Replace PaypalBackend.verify_ipn to not call the PayPal API
|
||||
# we will assume the IPN is authentic
|
||||
backend.verify_ipn = lambda request: True
|
||||
|
||||
self.assertEqual(subscription.status, 'new')
|
||||
|
||||
# 1. the subscr_payment IPN
|
||||
ipn_url = '/payments/callback/paypal_subscr/%d' % subscription.id
|
||||
ipn_request = RequestFactory().post(
|
||||
ipn_url,
|
||||
content_type='application/x-www-form-urlencoded',
|
||||
data=PAYPAL_IPN_SUBSCR_PAYMENT)
|
||||
r = backend.callback_subscr(subscription, ipn_request)
|
||||
|
||||
self.assertTrue(r)
|
||||
self.assertEqual(subscription.status, 'active')
|
||||
self.assertEqual(subscription.backend_extid, 'I-1S262863X133')
|
||||
|
||||
payments = Payment.objects.filter(subscription=subscription).all()
|
||||
self.assertEqual(len(payments), 1)
|
||||
self.assertEqual(payments[0].amount, 900)
|
||||
self.assertEqual(payments[0].paid_amount, 900)
|
||||
self.assertEqual(payments[0].backend_extid, '097872679P963871Y')
|
||||
|
||||
# 2. the subscr_signup IPN
|
||||
# We don't expect anything to happen here
|
||||
ipn_url = '/payments/callback/paypal_subscr/%d' % subscription.id
|
||||
ipn_request = RequestFactory().post(
|
||||
ipn_url,
|
||||
content_type='application/x-www-form-urlencoded',
|
||||
data=PAYPAL_IPN_SUBSCR_SIGNUP)
|
||||
r = backend.callback_subscr(subscription, ipn_request)
|
||||
|
||||
self.assertTrue(r)
|
||||
self.assertEqual(subscription.status, 'active')
|
||||
self.assertEqual(subscription.backend_extid, 'I-1S262863X133')
|
||||
|
||||
payments = Payment.objects.filter(subscription=subscription).all()
|
||||
self.assertEqual(len(payments), 1)
|
||||
self.assertEqual(payments[0].amount, 900)
|
||||
self.assertEqual(payments[0].paid_amount, 900)
|
||||
self.assertEqual(payments[0].backend_extid, '097872679P963871Y')
|
||||
|
||||
# 3. the subscr_cancel IPN
|
||||
ipn_url = '/payments/callback/paypal_subscr/%d' % subscription.id
|
||||
ipn_request = RequestFactory().post(
|
||||
ipn_url,
|
||||
content_type='application/x-www-form-urlencoded',
|
||||
data=PAYPAL_IPN_SUBSCR_CANCEL)
|
||||
r = backend.callback_subscr(subscription, ipn_request)
|
||||
|
||||
self.assertTrue(r)
|
||||
self.assertEqual(subscription.status, 'cancelled')
|
||||
self.assertEqual(subscription.backend_extid, 'I-1S262863X133')
|
||||
|
||||
payments = Payment.objects.filter(subscription=subscription).all()
|
||||
self.assertEqual(len(payments), 1)
|
||||
|
@ -1,23 +0,0 @@
|
||||
from django.conf.urls import url
|
||||
from . import views
|
||||
|
||||
app_name = 'payments'
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^new$', views.new),
|
||||
url(r'^view/(?P<id>[0-9]+)$', views.view, name='view'),
|
||||
url(r'^cancel/(?P<id>[0-9]+)$', views.cancel, name='cancel'),
|
||||
url(r'^cancel_subscr/(?P<id>[0-9]+)?$', views.cancel_subscr, name='cancel_subscr'),
|
||||
url(r'^return_subscr/(?P<id>[0-9]+)$', views.return_subscr, name='return_subscr'),
|
||||
|
||||
url(r'^callback/paypal/(?P<id>[0-9]+)$', views.payment_callback('paypal'), name='cb_paypal'),
|
||||
url(r'^callback/coingate/(?P<id>[0-9]+)$', views.payment_callback('coingate'), name='cb_coingate'),
|
||||
url(r'^callback/stripe/(?P<id>[0-9]+)$', views.payment_callback('stripe'), name='cb_stripe'),
|
||||
url(r'^callback/coinbase/$', views.plain_callback('coinbase'), name='cb_coinbase'),
|
||||
url(r'^callback/coinpayments/(?P<id>[0-9]+)$', views.payment_callback('coinpayments'), name='cb_coinpayments'),
|
||||
url(r'^callback/paypal_subscr/(?P<id>[0-9]+)$', views.sub_callback('paypal'), name='cb_paypal_subscr'),
|
||||
|
||||
url(r'^callback/stripe_hook$', views.stripe_hook, name='stripe_hook'),
|
||||
|
||||
url(r'^$', views.list_payments),
|
||||
]
|
@ -1,189 +0,0 @@
|
||||
from datetime import timedelta
|
||||
from django.shortcuts import render, redirect
|
||||
from django.urls import reverse
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotFound, Http404
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.utils import timezone
|
||||
from django.contrib import messages
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from .forms import NewPaymentForm
|
||||
from .models import Payment, Subscription, BACKENDS, ACTIVE_BACKENDS, Feedback
|
||||
|
||||
|
||||
def require_backend(name):
|
||||
backend = BACKENDS.get(name)
|
||||
if not backend:
|
||||
raise Http404()
|
||||
if not backend.backend_enabled:
|
||||
raise Http404()
|
||||
return backend
|
||||
|
||||
|
||||
@login_required
|
||||
def new(request):
|
||||
if request.method != 'POST':
|
||||
return redirect('account:index')
|
||||
|
||||
if Payment.objects.filter(user=request.user, status='new').count() > 10:
|
||||
messages.error(request, "Too many open payments.")
|
||||
return redirect('account:index')
|
||||
|
||||
form = NewPaymentForm(request.POST)
|
||||
|
||||
if not form.is_valid():
|
||||
return redirect('account:index')
|
||||
|
||||
if request.user.vpnuser.get_subscription() is not None:
|
||||
return redirect('account:index')
|
||||
|
||||
subscr = form.cleaned_data['subscr'] == '1'
|
||||
backend_id = form.cleaned_data['method']
|
||||
months = int(form.cleaned_data['time'])
|
||||
|
||||
if backend_id not in ACTIVE_BACKENDS:
|
||||
return HttpResponseNotFound()
|
||||
|
||||
if subscr:
|
||||
if months not in (3, 6, 12):
|
||||
return redirect('account:index')
|
||||
|
||||
rps = Subscription(
|
||||
user=request.user,
|
||||
backend_id=backend_id,
|
||||
period=str(months) + 'm',
|
||||
)
|
||||
rps.save()
|
||||
|
||||
r = rps.backend.new_subscription(rps)
|
||||
|
||||
else:
|
||||
payment = Payment.create_payment(backend_id, request.user, months)
|
||||
payment.save()
|
||||
|
||||
r = payment.backend.new_payment(payment)
|
||||
|
||||
if not r:
|
||||
payment.status = 'error'
|
||||
payment.save()
|
||||
raise Exception("Failed to initialize payment #%d" % payment.id)
|
||||
|
||||
if isinstance(r, str):
|
||||
return render(request, 'payments/form.html', dict(html=r))
|
||||
elif r is None:
|
||||
return redirect('payments:view', payment.id)
|
||||
|
||||
return r
|
||||
|
||||
def plain_callback(backend_name, method='callback'):
|
||||
@csrf_exempt
|
||||
def callback(request):
|
||||
backend = require_backend(backend_name)
|
||||
|
||||
m = getattr(backend, method)
|
||||
if m and m(Payment, request):
|
||||
return HttpResponse()
|
||||
else:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
return callback
|
||||
|
||||
def payment_callback(backend_name):
|
||||
@csrf_exempt
|
||||
def callback(request, id):
|
||||
backend = require_backend(backend_name)
|
||||
p = Payment.objects.get(id=id)
|
||||
|
||||
if backend.callback(p, request):
|
||||
return HttpResponse()
|
||||
else:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
return callback
|
||||
|
||||
def sub_callback(backend_name):
|
||||
@csrf_exempt
|
||||
def callback(request, id):
|
||||
backend = require_backend(backend_name)
|
||||
|
||||
p = Subscription.objects.get(id=id)
|
||||
|
||||
if backend.callback_subscr(p, request):
|
||||
return HttpResponse()
|
||||
else:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
return callback
|
||||
|
||||
@csrf_exempt
|
||||
def stripe_hook(request):
|
||||
backend = require_backend('stripe')
|
||||
|
||||
if backend.webhook(request):
|
||||
return HttpResponse()
|
||||
else:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
|
||||
@login_required
|
||||
@csrf_exempt
|
||||
def view(request, id):
|
||||
p = Payment.objects.get(id=id, user=request.user)
|
||||
return render(request, 'payments/view.html', dict(payment=p))
|
||||
|
||||
|
||||
@login_required
|
||||
def cancel(request, id):
|
||||
p = Payment.objects.get(id=id, user=request.user)
|
||||
if p.status == 'new':
|
||||
p.status = 'cancelled'
|
||||
p.save()
|
||||
return render(request, 'payments/view.html', dict(payment=p))
|
||||
|
||||
|
||||
@login_required
|
||||
def cancel_subscr(request, id=None):
|
||||
if request.method == 'POST' and id:
|
||||
p = Subscription.objects.get(id=id, user=request.user)
|
||||
|
||||
# Saving any feedback note
|
||||
feedback = request.POST.get('feedback')
|
||||
if feedback:
|
||||
feedback = feedback[:10000]
|
||||
f = Feedback(user=request.user, subscription=p, message=feedback)
|
||||
f.save()
|
||||
|
||||
try:
|
||||
if p.backend.cancel_subscription(p):
|
||||
messages.add_message(request, messages.INFO, _("Subscription cancelled!"))
|
||||
else:
|
||||
messages.add_message(request, messages.ERROR, _("Could not cancel the subscription. It may have already been cancelled or caused an error."))
|
||||
except NotImplementedError:
|
||||
pass
|
||||
return redirect('account:index')
|
||||
|
||||
subscription = request.user.vpnuser.get_subscription(include_unconfirmed=True)
|
||||
return render(request, 'payments/cancel_subscr.html', {
|
||||
'subscription': subscription,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def return_subscr(request, id):
|
||||
p = Subscription.objects.get(id=id, user=request.user)
|
||||
if p.status == 'new':
|
||||
p.status = 'unconfirmed'
|
||||
p.save()
|
||||
return redirect('account:index')
|
||||
|
||||
|
||||
@login_required
|
||||
def list_payments(request):
|
||||
# Only show recent cancelled payments
|
||||
cancelled_limit = timezone.now() - timedelta(days=3)
|
||||
|
||||
objects = request.user.payment_set.exclude(status='cancelled',
|
||||
created__lte=cancelled_limit)
|
||||
return render(request, 'payments/list.html', dict(payments=objects))
|
Loading…
Reference in New Issue