""" external library imports """ import datetime import uuid from django.utils.http import urlquote import logging from decimal import Decimal from jsonfield import JSONField """ django imports """ from django.conf import settings from django.contrib.auth.models import User from django.db import models from django.db.models import Q from django.contrib.sites.models import Site from django.db.models.signals import post_save, post_delete, pre_save """ django module imports """ from notification import models as notification """ regluit imports """ from regluit.payment.parameters import * from regluit.payment.signals import credit_balance_added, pledge_created from regluit.utils.localdatetime import now, date_today logger = logging.getLogger(__name__) # in fitting stripe -- here are possible fields to fit in with Transaction # c.id, c.amount, c.amount_refunded, c.currency, c.description, datetime.fromtimestamp(c.created, tz=utc), c.paid, # c.fee, c.disputed, c.amount_refunded, c.failure_message, # c.card.fingerprint, c.card.type, c.card.last4, c.card.exp_month, c.card.exp_year # promising fields class Transaction(models.Model): # type e.g., PAYMENT_TYPE_INSTANT or PAYMENT_TYPE_AUTHORIZATION -- defined in parameters.py type = models.IntegerField(default=PAYMENT_TYPE_NONE, null=False) # host: the payment processor. Named after the payment module that hosts the payment processing functions host = models.CharField(default=PAYMENT_HOST_NONE, max_length=32, null=False) #execution: e.g. EXECUTE_TYPE_CHAINED_INSTANT, EXECUTE_TYPE_CHAINED_DELAYED, EXECUTE_TYPE_PARALLEL execution = models.IntegerField(default=EXECUTE_TYPE_NONE, null=False) # status: general status constants defined in parameters.py status = models.CharField(max_length=32, default=TRANSACTION_STATUS_NONE, null=False) # local_status: status code specific to the payment processor local_status = models.CharField(max_length=32, default='NONE', null=True) # amount & currency -- amount of money and its currency involved for transaction amount = models.DecimalField(default=Decimal('0.00'), max_digits=14, decimal_places=2) # max 999,999,999,999.99 max_amount = models.DecimalField(default=Decimal('0.00'), max_digits=14, decimal_places=2) # max 999,999,999,999.99 currency = models.CharField(max_length=10, default='USD', null=True) # a unique ID that can be passed to PayPal to track a transaction secret = models.CharField(max_length=64, null=True) # a paykey that PayPal generates to identify this transaction pay_key = models.CharField(max_length=128, null=True) # a preapproval key that Paypal generates to identify this transaction preapproval_key = models.CharField(max_length=128, null=True) # (RY is not sure what receipt is for; t4u has hijacked this to be an email address for user.is_anonymous to send a receipt to) receipt = models.CharField(max_length=256, null=True) # whether a Preapproval has been approved or not approved = models.NullBooleanField(null=True) # error message from a transaction error = models.CharField(max_length=256, null=True) # IPN.reason_code reason = models.CharField(max_length=64, null=True) # creation and last modified timestamps date_created = models.DateTimeField(auto_now_add=True, db_index=True,) date_modified = models.DateTimeField(auto_now=True) # date_payment: when an attempt is made to make the primary payment date_payment = models.DateTimeField(null=True) # date_executed: when an attempt is made to send money to non-primary chained receivers date_executed = models.DateTimeField(null=True) # datetime for creation of preapproval and for its expiration date_authorized = models.DateTimeField(null=True) date_expired = models.DateTimeField(null=True) # associated User, Campaign, and Premium for this Transaction user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True) campaign = models.ForeignKey('core.Campaign', null=True) premium = models.ForeignKey('core.Premium', null=True) offer = models.ForeignKey('core.Offer', null=True) extra = JSONField(null=True, default={}) # whether the user wants to be not listed publicly anonymous = models.BooleanField(default=False) # whether the transaction represents a donation donation = models.BooleanField(default=False) @property def tier(self): if self.amount < 25: return 0 if self.amount < 50: return 1 if self.amount < 100: return 2 else: return 3 @property def deadline_or_now(self): if self.campaign and self.campaign.deadline: return self.campaign.deadline else: return now() @property def needed_amount(self): if self.user == None or self.user.is_anonymous(): return self.max_amount if self.user.credit.available >= self.max_amount: return 0 else: return self.max_amount - self.user.credit.available @property def credit_amount(self): if self.user == None or self.user.is_anonymous(): return 0 if self.user.credit.available >= self.max_amount: return self.max_amount return self.user.credit.available @property def ack_link(self): return 'https://unglue.it/supporter/%s' % urlquote(self.user.username) if not self.anonymous else '' def save(self, *args, **kwargs): if not self.secret: self.secret = str(uuid.uuid1()) super(Transaction, self).save(*args, **kwargs) # Call the "real" save() method. def __unicode__(self): return u"-- Transaction:\n \tstatus: %s\n \t amount: %s\n \terror: %s\n" % (self.status, str(self.amount), self.error) def create_receivers(self, receiver_list): primary = True for r in receiver_list: receiver = Receiver.objects.create(email=r['email'], amount=r['amount'], currency=self.currency, status="None", primary=primary, transaction=self) primary = False def get_payment_class(self): ''' Returns the specific payment processor that implements this transaction ''' if self.host == PAYMENT_HOST_NONE: return None else: mod = __import__("regluit.payment." + self.host, fromlist=[str(self.host)]) return mod.Processor() def set_executed(self): self.date_executed = now() self.save() def set_payment(self): self.date_payment = now() self.save() def set_credit_approved(self, amount): self.amount=amount self.host = PAYMENT_HOST_CREDIT self.type = PAYMENT_TYPE_AUTHORIZATION self.status=TRANSACTION_STATUS_ACTIVE self.approved=True now_val = now() self.date_authorized = now_val self.date_expired = now_val + datetime.timedelta( days=settings.PREAPPROVAL_PERIOD ) self.save() pledge_created.send(sender=self, transaction=self) def set_pledge_extra(self, pledge_extra): if pledge_extra: self.anonymous = pledge_extra.anonymous self.premium = pledge_extra.premium self.offer = pledge_extra.offer self.extra.update( pledge_extra.extra) def get_pledge_extra(self): class pe: anonymous=self.anonymous premium=self.premium offer=self.offer extra=self.extra return pe @classmethod def create(cls, amount=0.00, host=PAYMENT_HOST_NONE, max_amount=0.00, currency='USD', status=TRANSACTION_STATUS_NONE, campaign=None, user=None, pledge_extra=None, donation=False): if user and user.is_anonymous(): user = None t = cls.objects.create( amount=amount, host=host, max_amount=max_amount, currency=currency, status=status, campaign=campaign, user=user, donation=donation, ) if pledge_extra: t.set_pledge_extra(pledge_extra) return t class PaymentResponse(models.Model): # The API used api = models.CharField(max_length=64, null=False) # The correlation ID correlation_id = models.CharField(max_length=512, null=True) # the paypal timestamp timestamp = models.CharField(max_length=128, null=True) # extra info we want to store if an error occurs such as the response message info = models.CharField(max_length=1024, null=True) # local status specific to the api call status = models.CharField(max_length=32, null=True) transaction = models.ForeignKey(Transaction, null=False) def __unicode__(self): return u"PaymentResponse -- api: {0} correlation_id: {1} transaction: {2}".format(self.api, self.correlation_id, unicode(self.transaction)) class Receiver(models.Model): email = models.CharField(max_length=64) amount = models.DecimalField(default=Decimal('0.00'), max_digits=14, decimal_places=2) # max 999,999,999,999.99 currency = models.CharField(max_length=10) status = models.CharField(max_length=64) local_status = models.CharField(max_length=64, null=True) reason = models.CharField(max_length=64) primary = models.BooleanField(default=True) txn_id = models.CharField(max_length=64) transaction = models.ForeignKey(Transaction) def __unicode__(self): return u"Receiver -- email: {0} status: {1} transaction: {2}".format(self.email, self.status, unicode(self.transaction)) class CreditLog(models.Model): # a write only record of Unglue.it Credit Transactions user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True) amount = models.DecimalField(default=Decimal('0.00'), max_digits=14, decimal_places=2) # max 999,999,999,999.99 timestamp = models.DateTimeField(auto_now=True) action = models.CharField(max_length=16) # used to record the sent id when action = 'deposit' sent=models.IntegerField(null=True) class Credit(models.Model): user = models.OneToOneField(settings.AUTH_USER_MODEL, related_name='credit') balance = models.DecimalField(default=Decimal('0.00'), max_digits=14, decimal_places=2) # max 999,999,999,999.99 pledged = models.DecimalField(default=Decimal('0.00'), max_digits=14, decimal_places=2) # max 999,999,999,999.99 last_activity = models.DateTimeField(auto_now=True) @property def available(self): return self.balance - self.pledged def add_to_balance(self, num_credits, notify=True): if self.pledged - self.balance > num_credits : # negative to withdraw return False else: self.balance = self.balance + num_credits self.save() try: # bad things can happen here if you don't return True CreditLog(user = self.user, amount = num_credits, action="add_to_balance").save() except: logger.exception("failed to log add_to_balance of %s", num_credits) if notify: try: credit_balance_added.send(sender=self, amount=num_credits) except: logger.exception("credit_balance_added notification failed of %s", num_credits) return True def add_to_pledged(self, num_credits): num_credits=Decimal(num_credits) if num_credits is Decimal('NaN'): return False if self.balance - self.pledged < num_credits : return False else: self.pledged=self.pledged + num_credits self.save() try: # bad things can happen here if you don't return True CreditLog(user = self.user, amount = num_credits, action="add_to_pledged").save() except: logger.exception("failed to log add_to_pledged of %s", num_credits) return True def use_pledge(self, num_credits): num_credits=Decimal(num_credits) if num_credits is Decimal('NaN'): return False if self.pledged < num_credits : return False self.pledged = self.pledged - num_credits self.balance = self.balance - num_credits self.save() try: CreditLog(user = self.user, amount = - num_credits, action="use_pledge").save() except: logger.exception("failed to log use_pledge of %s", num_credits) return True def transfer_to(self, receiver, num_credits, notify=True): num_credits=Decimal(num_credits) if num_credits is Decimal('NaN') or not isinstance( receiver, User): logger.info('fail: %s, %s' % (num_credits,receiver)) return False if self.add_to_balance(-num_credits): if receiver.credit.add_to_balance(num_credits, notify): return True else: # unwind transfer self.add_to_balance(num_credits, notify) return False else: return False class Sent(models.Model): '''used by gift view to record gifts it has sent''' user = models.CharField(max_length=32, null=True) amount = models.DecimalField(default=Decimal('0.00'), max_digits=14, decimal_places=2) # max 999,999,999,999.99 timestamp = models.DateTimeField(auto_now=True) class Account(models.Model): """holds references to accounts at third party payment gateways, especially for representing credit cards""" # the following fields from stripe Customer might be relevant to Account -- we need to pick good selection # c.id, c.description, c.email, datetime.fromtimestamp(c.created, tz=utc), c.account_balance, c.delinquent, # c.active_card.fingerprint, c.active_card.type, c.active_card.last4, c.active_card.exp_month, c.active_card.exp_year, # c.active_card.country # ACTIVE, DEACTIVATED, EXPIRED, EXPIRING, or ERROR STATUS_CHOICES = ( ('ACTIVE','ACTIVE'), ('DEACTIVATED','DEACTIVATED'), ('EXPIRED','EXPIRED'), ('EXPIRING','EXPIRING'), ( 'ERROR','ERROR') ) # host: the payment processor. Named after the payment module that hosts the payment processing functions host = models.CharField(default=PAYMENT_HOST_NONE, max_length=32, null=False) account_id = models.CharField(max_length=128, null=True) # card related info card_last4 = models.CharField(max_length=4, null=True) # Visa, American Express, MasterCard, Discover, JCB, Diners Club, or Unknown card_type = models.CharField(max_length=32, null=True) card_exp_month = models.IntegerField(null=True) card_exp_year = models.IntegerField(null=True) card_fingerprint = models.CharField(max_length=32, null=True) card_country = models.CharField(max_length=2, null=True) # creation and last modified timestamps date_created = models.DateTimeField(auto_now_add=True) date_modified = models.DateTimeField(auto_now=True) date_deactivated = models.DateTimeField(null=True) # associated User if any user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True) # status variable status = models.CharField(max_length=11, choices=STATUS_CHOICES, null=False, default='ACTIVE') def deactivate(self): """Don't allow more than one active Account of given host to be associated with a given user""" self.date_deactivated = now() self.status = 'DEACTIVATED' self.save() def calculated_status(self): """returns ACTIVE, DEACTIVATED, EXPIRED, EXPIRING, or ERROR""" # is it deactivated? today = date_today() if self.date_deactivated is not None: return 'DEACTIVATED' # is it expired? elif self.card_exp_year < today.year or (self.card_exp_year == today.year and self.card_exp_month < today.month): return 'EXPIRED' # about to expire? do I want to distinguish from 'ACTIVE'? elif (self.card_exp_year == today.year and self.card_exp_month == today.month): return 'EXPIRING' # any transactions w/ errors after the account date? # Transaction.objects.filter(host='stripelib', status='Error', approved=True).count() elif Transaction.objects.filter(host='stripelib', status='Error', approved=True, user=self.user).filter(date_payment__gt=self.date_created): return 'ERROR' else: return 'ACTIVE' def update_status( self, value=None, send_notice_on_change_only=True): """set Account.status = value unless value is None, in which case, we set Account.status=self.calculated_status() fire off associated notifications By default, send notices only if the status is *changing*. Set send_notice_on_change_only = False to send notice based on new_status regardless of old status. (Useful for initialization) """ old_status = self.status if value is None: new_status = self.calculated_status() else: new_status = value if new_status == 'EXPIRED': self.deactivate() elif old_status != new_status: self.status = new_status self.save() # don't notify null users (non-users can buy-to-unglue or thank-for-ungluing) if self.user and (not send_notice_on_change_only or (old_status != new_status)): logger.info( "Account status change: %d %s %s", self.pk, old_status, new_status) if new_status == 'EXPIRING': logger.info( "EXPIRING. send to instance.user: %s site: %s", self.user, Site.objects.get_current()) # fire off an account_expiring notice -- might not want to do this immediately notification.queue([self.user], "account_expiring", { 'user': self.user, 'site':Site.objects.get_current() }, True) elif new_status == 'EXPIRED': logger.info( "EXPIRING. send to instance.user: %s site: %s", self.user, Site.objects.get_current()) notification.queue([self.user], "account_expired", { 'user': self.user, 'site':Site.objects.get_current() }, True) elif new_status == 'ERROR': # TO DO: what to do? pass elif new_status == 'DEACTIVATED': # nothing needs to happen here pass def recharge_failed_transactions(self): """When a new Account is saved, check whether this is the new active account for a user. If so, recharge any outstanding failed transactions """ transactions_to_recharge = self.user.transaction_set.filter((Q(status=TRANSACTION_STATUS_FAILED) | Q(status=TRANSACTION_STATUS_ERROR)) & Q(campaign__status='SUCCESSFUL')).all() if transactions_to_recharge: from regluit.payment.manager import PaymentManager pm = PaymentManager() for transaction in transactions_to_recharge: # check whether we are still within the window to recharge if (now() - transaction.deadline_or_now) < datetime.timedelta(settings.RECHARGE_WINDOW): logger.info("Recharging transaction {0} w/ status {1}".format(transaction.id, transaction.status)) pm.execute_transaction(transaction, []) # handle any save, updates to a payment.Transaction def handle_transaction_change(sender, instance, created, **kwargs): campaign = instance.campaign if campaign: campaign.update_left() return True # handle any deletes of payment.Transaction def handle_transaction_delete(sender, instance, **kwargs): campaign = instance.campaign if campaign: campaign.update_left() return True post_save.connect(handle_transaction_change,sender=Transaction) post_delete.connect(handle_transaction_delete,sender=Transaction) # handle recharging failed transactions