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