|
|
|
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))
|
|
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
subscription = models.ForeignKey('Subscription', null=True, blank=True, on_delete=models.CASCADE)
|
|
|
|
status_message = models.TextField(blank=True, null=True)
|
|
|
|
|
|
|
|
backend_extid = models.CharField(max_length=256, null=True, blank=True)
|
|
|
|
backend_data = JSONField(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)
|
|
|
|
period = models.CharField(max_length=16, choices=SUBSCR_PERIOD_CHOICES)
|
|
|
|
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()
|
|
|
|
|