Merge pull request #86 from Gluejar/charge_failed

Charge failed
pull/1/head
Raymond Yee 2012-11-27 12:57:32 -08:00
commit 3d5f8a55f4
24 changed files with 694 additions and 104 deletions

View File

@ -301,24 +301,29 @@ class Campaign(models.Model):
self.save()
action = CampaignAction(campaign=self, type='succeeded', comment = self.current_total)
action.save()
if send_notice:
successful_campaign.send(sender=None,campaign=self)
if process_transactions:
p = PaymentManager()
results = p.execute_campaign(self)
# should be more sophisticated in whether to return True -- look at all the transactions
if send_notice:
successful_campaign.send(sender=None,campaign=self)
# should be more sophisticated in whether to return True -- look at all the transactions?
return True
elif self.deadline < now() and self.current_total < self.target:
self.status = 'UNSUCCESSFUL'
self.save()
action = CampaignAction(campaign=self, type='failed', comment = self.current_total)
action.save()
if send_notice:
regluit.core.signals.unsuccessful_campaign.send(sender=None,campaign=self)
if process_transactions:
p = PaymentManager()
results = p.cancel_campaign(self)
# should be more sophisticated in whether to return True -- look at all the transactions
results = p.cancel_campaign(self)
if send_notice:
regluit.core.signals.unsuccessful_campaign.send(sender=None,campaign=self)
# should be more sophisticated in whether to return True -- look at all the transactions?
return True
else:
return False

View File

@ -8,13 +8,16 @@ from django.contrib.sites.models import Site
from django.conf import settings
from django.utils.translation import ugettext_noop as _
import datetime
from regluit.utils.localdatetime import now
from notification import models as notification
from social_auth.signals import pre_update
from social_auth.backends.facebook import FacebookBackend
from tastypie.models import create_api_key
from regluit.payment.signals import transaction_charged, pledge_modified, pledge_created
from regluit.payment.signals import transaction_charged, transaction_failed, pledge_modified, pledge_created
import registration.signals
import django.dispatch
@ -31,20 +34,21 @@ def facebook_extra_values(sender, user, response, details, **kwargs):
pre_update.connect(facebook_extra_values, sender=FacebookBackend)
# create Wishlist and UserProfile to associate with User
def create_user_objects(sender, created, instance, **kwargs):
# use get_model to avoid circular import problem with models
try:
Wishlist = get_model('core', 'Wishlist')
UserProfile = get_model('core', 'UserProfile')
if created:
Wishlist.objects.create(user=instance)
UserProfile.objects.create(user=instance)
except DatabaseError:
# this can happen when creating superuser during syncdb since the
# core_wishlist table doesn't exist yet
return
# don't create Wishlist or UserProfile if we are loading fixtures http://stackoverflow.com/a/3500009/7782
if not kwargs.get('raw', False):
try:
Wishlist = get_model('core', 'Wishlist')
UserProfile = get_model('core', 'UserProfile')
if created:
Wishlist.objects.create(user=instance)
UserProfile.objects.create(user=instance)
except DatabaseError:
# this can happen when creating superuser during syncdb since the
# core_wishlist table doesn't exist yet
return
post_save.connect(create_user_objects, sender=User)
@ -93,6 +97,7 @@ def create_notice_types(app, created_models, verbosity, **kwargs):
notification.create_notice_type("pledge_you_have_pledged", _("Thanks For Your Pledge!"), _("Your ungluing pledge has been entered."))
notification.create_notice_type("pledge_status_change", _("Your Pledge Has Been Modified"), _("Your ungluing pledge has been modified."))
notification.create_notice_type("pledge_charged", _("Your Pledge has been Executed"), _("You have contributed to a successful ungluing campaign."))
notification.create_notice_type("pledge_failed", _("Unable to charge your credit card"), _("A charge to your credit card did not go through."))
notification.create_notice_type("rights_holder_created", _("Agreement Accepted"), _("You have become a verified Unglue.it rights holder."))
notification.create_notice_type("rights_holder_claim_approved", _("Claim Accepted"), _("A claim you've entered has been accepted."))
notification.create_notice_type("wishlist_unsuccessful_amazon", _("Campaign shut down"), _("An ungluing campaign that you supported had to be shut down due to an Amazon Payments policy change."))
@ -170,6 +175,26 @@ def handle_transaction_charged(sender,transaction=None, **kwargs):
transaction_charged.connect(handle_transaction_charged)
# dealing with failed transactions
def handle_transaction_failed(sender,transaction=None, **kwargs):
if transaction is None:
return
# window for recharging
recharge_deadline = transaction.campaign.deadline + datetime.timedelta(settings.RECHARGE_WINDOW)
notification.queue([transaction.user], "pledge_failed", {
'site':Site.objects.get_current(),
'transaction':transaction,
'recharge_deadline': recharge_deadline
}, True)
from regluit.core.tasks import emit_notifications
emit_notifications.delay()
transaction_failed.connect(handle_transaction_failed)
def handle_pledge_modified(sender, transaction=None, up_or_down=None, **kwargs):
# we need to know if pledges were modified up or down because Amazon handles the
# transactions in different ways, resulting in different user-visible behavior;
@ -264,4 +289,4 @@ def handle_wishlist_near_deadline(campaign, **kwargs):
from regluit.core.tasks import emit_notifications
emit_notifications.delay()
deadline_impending.connect(handle_wishlist_near_deadline)
deadline_impending.connect(handle_wishlist_near_deadline)

View File

@ -327,9 +327,6 @@ class BookLoaderTests(TestCase):
ebook = bookloader.load_gutenberg_edition(title, gutenberg_etext_id, ol_work_id, seed_isbn, epub_url, format, license, lang, publication_date)
self.assertEqual(ebook.url, epub_url)
class SearchTests(TestCase):
@ -381,7 +378,6 @@ class CampaignTests(TestCase):
self.assertEqual(c.license_url, 'http://creativecommons.org/licenses/by-nc/3.0/')
self.assertEqual(c.license_badge, 'https://i.creativecommons.org/l/by-nc/3.0/88x31.png')
def test_campaign_status(self):
# need a user to associate with a transaction
@ -696,4 +692,4 @@ class LocaldatetimeTest(TestCase):
reload(localdatetime)

View File

@ -58,7 +58,8 @@ def grouper(iterable, page_size):
if len(page) == page_size:
yield page
page= []
yield page
if len(page):
yield page
class FreebaseException(Exception):
pass

View File

@ -24,7 +24,6 @@ import operator
import time
import re
from itertools import islice, izip
import logging
import random
import json

View File

@ -1,6 +1,6 @@
{% load humanize %}Congratulations!
Thanks to you and other ungluers, {{ transaction.campaign.work.title }} will be released to the world in an unglued ebook edition. Your credit card will be charged ${{ transaction.amount|intcomma }}.
Thanks to you and other ungluers, {{ transaction.campaign.work.title }} will be released to the world in an unglued ebook edition. Your credit card has been charged ${{ transaction.amount|intcomma }}.
Pledge summary
{% include "notification/pledge_summary.txt" %}

View File

@ -6,13 +6,13 @@
{% endblock %}
{% block comments_graphical %}
Hooray! The campaign for <a href="{% url work transaction.campaign.work.id %}">{{ transaction.campaign.work.title }}</a> has succeeded. Your credit card is being charged ${{ transaction.amount }}. Thank you again for your help.
Hooray! The campaign for <a href="{% url work transaction.campaign.work.id %}">{{ transaction.campaign.work.title }}</a> has succeeded. Your credit card has been charged ${{ transaction.amount|intcomma }}. Thank you again for your help.
{% endblock %}
{% block comments_textual %}
<p>Congratulations!</p>
<p>Thanks to you and other ungluers, {{ transaction.campaign.work.title }} will be released to the world in an unglued ebook edition. {{ transaction.host|capfirst }} will now charge your credit card.</p>
<p>Thanks to you and other ungluers, {{ transaction.campaign.work.title }} will be released to the world in an unglued ebook edition. {{ transaction.host|capfirst }} has been charged to your credit card.</p>
<p><b>Pledge Summary</b><br />
Amount pledged: {{ transaction.amount|intcomma }}<br />

View File

@ -1 +1 @@
Thanks to you, the campaign for {{transaction.campaign.work.title}} has succeeded!
Your pledge to the campaign to unglue {{transaction.campaign.work.title}} has been charged.

View File

@ -0,0 +1,15 @@
{% load humanize %}Thanks to you and other ungluers, {{ transaction.campaign.work.title }} will be released to the world in an unglued ebook edition.
However, our attempt to charge your credit card in the amount of ${{ transaction.amount|intcomma }} failed ({{transaction.error}}). Don't worry -- typically this means the card on file for you is expired, and once you update your card information we'll be able to collect your pledge on behalf of {{ transaction.campaign.rightsholder }}. Please update your credit card information at
https://{{ site.domain }}{% url manage_account %} by {{ recharge_deadline }} so that you can fulfill your pledge. Thank you!
Pledge summary
{% include "notification/pledge_summary.txt" %}
If you'd like to visit the campaign page, click here:
https://{{ site.domain }}{% url work transaction.campaign.work.id %}
Thank you again for your support.
{{ transaction.campaign.rightsholder }} and the Unglue.it team

View File

@ -0,0 +1,34 @@
{% extends "notification/notice_template.html" %}
{% load humanize %}
{% block comments_book %}
<a href="{% url work transaction.campaign.work.id %}"><img src="{{ transaction.campaign.work.cover_image_small }}" alt="cover image for {{ transaction.campaign.work.title }}" /></a>
{% endblock %}
{% block comments_graphical %}
The campaign for <a href="{% url work transaction.campaign.work.id %}">{{ transaction.campaign.work.title }}</a> has succeeded. However, our attempt to charge your pledge for ${{ transaction.amount|intcomma }} to your credit card failed ({{transaction.error}}). Will you help us fix that?
{% endblock %}
{% block comments_textual %}
<p>Your attention needed!</p>
<p>Thanks to you and other ungluers, {{ transaction.campaign.work.title }} will be released to the world in an unglued ebook edition.
However, our attempt to charge your credit card for ${{ transaction.amount|intcomma }} failed.</p>
<p>Don't worry - normally this just means the card we have on file for you is expired. Once you've updated your card information we'll be able to collect your pledge on behalf of {{ transaction.campaign.rightsholder }}. Please update your credit card information at
https://{{ site.domain }}{% url manage_account %} by {{ recharge_deadline }} so that you can fulfill your pledge. Thank you!</p>
<p><b>Pledge Summary</b><br />
Amount pledged: {{ transaction.amount|intcomma }}<br />
Premium: {{ transaction.premium.description }} <br />
</p>
<p>We will notify you when the unglued ebook is available for you to read. If you've requested special premiums, the rights holder, {{ transaction.campaign.rightsholder }}, will be in touch with you via email to request any information needed to deliver your premium.
</p>
<p>For more information, visit the visit the <a href="{% url work transaction.campaign.work.id %}">campaign page</a>.
</p>
<p>Thank you again for your support.
</p>
<p>{{ transaction.campaign.rightsholder }} and the Unglue.it team
</p>
{% endblock %}

View File

@ -0,0 +1 @@
Attention needed: your pledge to unglue {{transaction.campaign.work.title}}

View File

@ -5,12 +5,20 @@ from django.test.client import Client
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from django.conf import settings
from django.core import mail
from regluit.core.models import Work, Campaign, RightsHolder, Claim
from regluit.payment.models import Transaction
from regluit.payment.manager import PaymentManager
from regluit.payment.stripelib import StripeClient, TEST_CARDS, ERROR_TESTING, card
from notification.models import Notice
from decimal import Decimal as D
from regluit.utils.localdatetime import now
from datetime import timedelta
import time
import json
class WishlistTests(TestCase):
@ -173,4 +181,265 @@ class PledgingUiTests(TestCase):
def tearDown(self):
pass
class UnifiedCampaignTests(TestCase):
fixtures=['basic_campaign_test.json']
def test_setup(self):
# testing basics: are there 3 users?
self.assertEqual(User.objects.count(), 3)
# make sure we know the passwords for the users
#RaymondYee / raymond.yee@gmail.com / Test_Password_
#hmelville / rdhyee@yahoo.com / gofish!
#dataunbound / raymond.yee@dataunbound.com / numbers_unbound
self.client = Client()
self.assertTrue(self.client.login(username="RaymondYee", password="Test_Password_"))
self.assertTrue(self.client.login(username="hmelville", password="gofish!"))
self.assertTrue(self.client.login(username="dataunbound", password="numbers_unbound"))
# how many works and campaigns?
self.assertEqual(Work.objects.count(), 3)
self.assertEqual(Campaign.objects.count(), 2)
def test_junk_webhook(self):
"""send in junk json and then an event that doesn't exist"""
# non-json
ipn_url = reverse("HandleIPN", args=('stripelib',))
r = self.client.post(ipn_url, data="X", content_type="application/json; charset=utf-8")
self.assertEqual(r.status_code, 400)
# junk event_id
r = self.client.post(ipn_url, data='{"id": "evt_XXXXXXXXX"}', content_type="application/json; charset=utf-8")
self.assertEqual(r.status_code, 400)
def pledge_to_work_with_cc(self, username, password, work_id, card, preapproval_amount='10', premium_id='150'):
# how much of test.campaigntest.test_relaunch can be done here?
self.assertTrue(self.client.login(username=username, password=password))
# Pro Web 2.0 Mashups
self.work = Work.objects.get(id=work_id)
r = self.client.get("/work/%s/" % (self.work.id))
r = self.client.get("/work/%s/" % self.work.id)
self.assertEqual(r.status_code, 200)
# go to pledge page
r = self.client.get("/pledge/%s" % self.work.id, data={}, follow=True)
self.assertEqual(r.status_code, 200)
# submit to pledge page
r = self.client.post("/pledge/%s/" % self.work.id, data={'preapproval_amount': preapproval_amount,
'premium_id':premium_id}, follow=True)
self.assertEqual(r.status_code, 200)
# Should now be on the fund page
pledge_fund_path = r.request.get('PATH_INFO')
self.assertTrue(pledge_fund_path.startswith('/pledge/fund'))
# pull out the transaction info
t_id = int(pledge_fund_path.replace('/pledge/fund/',''))
# r.content holds the page content
# create a stripe token to submit to form
# track start time and end time of these stipe interactions so that we can limit the window of Events to look for
time0 = time.time()
sc = StripeClient()
stripe_token = sc.create_token(card=card)
r = self.client.post(pledge_fund_path, data={'stripe_token':stripe_token.id}, follow=True)
# where are we now?
self.assertEqual(r.request.get('PATH_INFO'), '/pledge/complete/')
self.assertEqual(r.status_code, 200)
# dig up the transaction and charge it
pm = PaymentManager()
transaction = Transaction.objects.get(id=t_id)
# catch any exception and pass it along
try:
self.assertTrue(pm.execute_transaction(transaction, ()))
except Exception, charge_exception:
pass
else:
charge_exception = None
time1 = time.time()
# retrieve events from this period -- need to pass in ints for event creation times
events = list(sc._all_objs('Event', created={'gte':int(time0-1.0), 'lte':int(time1+1.0)}))
return (events, charge_exception)
def good_cc_scenario(self):
# how much of test.campaigntest.test_relaunch can be done here?
card1 = card(number=TEST_CARDS[0][0], exp_month=1, exp_year='2020', cvc='123', name='Raymond Yee',
address_line1="100 Jackson St.", address_line2="", address_zip="94706", address_state="CA", address_country=None) # good card
(events, charge_exception) = self.pledge_to_work_with_cc(username="RaymondYee", password="Test_Password_", work_id=1, card=card1,
preapproval_amount='10', premium_id='150')
self.assertEqual(charge_exception, None)
# expect to have 2 events (there is a possibility that someone else could be running tests on this stripe account at the same time)
# events returned sorted in reverse chronological order.
self.assertEqual(len(events), 2)
self.assertEqual(events[0].type, 'charge.succeeded')
self.assertEqual(events[1].type, 'customer.created')
# now feed each of the events to the IPN processor.
ipn_url = reverse("HandleIPN", args=('stripelib',))
for (i, event) in enumerate(events):
r = self.client.post(ipn_url, data=json.dumps({"id": event.id}), content_type="application/json; charset=utf-8")
self.assertEqual(r.status_code, 200)
# expected notices
self.assertEqual(len(Notice.objects.filter(notice_type__label='pledge_you_have_pledged', recipient__username='RaymondYee')), 1)
self.assertEqual(len(Notice.objects.filter(notice_type__label='pledge_charged', recipient__username='RaymondYee')), 1)
def bad_cc_scenario(self):
"""Goal of this scenario: enter a CC that will cause a charge.failed event, have user repledge succesfully"""
card1 = card(number=ERROR_TESTING['BAD_ATTACHED_CARD'][0])
(events, charge_exception) = self.pledge_to_work_with_cc(username="dataunbound", password="numbers_unbound", work_id=2, card=card1,
preapproval_amount='10', premium_id='150')
# we should have an exception when the charge was attempted
self.assertTrue(charge_exception is not None)
# expect to have 2 events (there is a possibility that someone else could be running tests on this stripe account at the same time)
# events returned sorted in reverse chronological order.
self.assertEqual(len(events), 2)
self.assertEqual(events[0].type, 'charge.failed')
self.assertEqual(events[1].type, 'customer.created')
# now feed each of the events to the IPN processor.
ipn_url = reverse("HandleIPN", args=('stripelib',))
for (i, event) in enumerate(events):
r = self.client.post(ipn_url, data=json.dumps({"id": event.id}), content_type="application/json; charset=utf-8")
self.assertEqual(r.status_code, 200)
self.assertEqual(len(Notice.objects.filter(notice_type__label='pledge_you_have_pledged', recipient__username='dataunbound')), 1)
self.assertEqual(len(Notice.objects.filter(notice_type__label='pledge_failed', recipient__username='dataunbound')), 1)
def recharge_with_new_card(self):
# mark campaign as SUCCESSFUL -- campaign for work 2
c = Work.objects.get(id=2).last_campaign()
c.status = 'SUCCESSFUL'
c.save()
# set up a good card
card1 = card(number=TEST_CARDS[0][0], exp_month=1, exp_year='2020', cvc='123', name='dataunbound',
address_line1="100 Jackson St.", address_line2="", address_zip="94706", address_state="CA", address_country=None) # good card
# track start time and end time of these stipe interactions so that we can limit the window of Events to look for
time0 = time.time()
sc = StripeClient()
stripe_token = sc.create_token(card=card1)
r = self.client.post("/accounts/manage/", data={'stripe_token':stripe_token.id}, follow=True)
time1 = time.time()
# retrieve events from this period -- need to pass in ints for event creation times
events = list(sc._all_objs('Event', created={'gte':int(time0-1.0), 'lte':int(time1+1.0)}))
# now feed each of the events to the IPN processor.
ipn_url = reverse("HandleIPN", args=('stripelib',))
for (i, event) in enumerate(events):
r = self.client.post(ipn_url, data=json.dumps({"id": event.id}), content_type="application/json; charset=utf-8")
self.assertEqual(r.status_code, 200)
# a charge should now go through
self.assertEqual(len(Notice.objects.filter(notice_type__label='pledge_charged', recipient__username='dataunbound')), 1)
def test_good_bad_cc_scenarios(self):
self.good_cc_scenario()
self.bad_cc_scenario()
self.recharge_with_new_card()
self.stripe_token_none()
self.confirm_num_mail()
def stripe_token_none(self):
"""Test that if an empty stripe_token is submitted to pledge page, we catch that issue and present normal error page to user"""
username="hmelville"
password="gofish!"
work_id =1
preapproval_amount='10'
premium_id='150'
self.assertTrue(self.client.login(username=username, password=password))
# Pro Web 2.0 Mashups
self.work = Work.objects.get(id=work_id)
r = self.client.get("/work/%s/" % (self.work.id))
r = self.client.get("/work/%s/" % self.work.id)
self.assertEqual(r.status_code, 200)
# go to pledge page
r = self.client.get("/pledge/%s" % self.work.id, data={}, follow=True)
self.assertEqual(r.status_code, 200)
# submit to pledge page
r = self.client.post("/pledge/%s/" % self.work.id, data={'preapproval_amount': preapproval_amount,
'premium_id':premium_id}, follow=True)
self.assertEqual(r.status_code, 200)
# Should now be on the fund page
pledge_fund_path = r.request.get('PATH_INFO')
self.assertTrue(pledge_fund_path.startswith('/pledge/fund'))
# pull out the transaction info
t_id = int(pledge_fund_path.replace('/pledge/fund/',''))
stripe_token = ''
r = self.client.post(pledge_fund_path, data={'stripe_token':stripe_token}, follow=True)
self.assertEqual(r.status_code, 200)
def confirm_num_mail(self):
# look at emails generated through these scenarios
#print len(mail.outbox)
#for (i, m) in enumerate(mail.outbox):
# print i, m.subject
# if i in [5]:
# print m.body
self.assertEqual(len(mail.outbox), 9)
# print out notices and eventually write tests here to check expected
#from notification.models import Notice
#print [(n.id, n.notice_type.label, n.recipient, n.added) for n in Notice.objects.all()]
#[(6L, u'pledge_charged', <User: dataunbound>, datetime.datetime(2012, 11, 21, 18, 33, 15)),
#(5L, u'pledge_failed', <User: dataunbound>, datetime.datetime(2012, 11, 21, 18, 33, 10)),
#(4L, u'new_wisher', <User: hmelville>, datetime.datetime(2012, 11, 21, 18, 33, 8)),
#(3L, u'pledge_you_have_pledged', <User: dataunbound>, datetime.datetime(2012, 11, 21, 18, 33, 7)),
#(2L, u'pledge_charged', <User: RaymondYee>, datetime.datetime(2012, 11, 21, 18, 33, 3)),
#(1L, u'pledge_you_have_pledged', <User: RaymondYee>, datetime.datetime(2012, 11, 21, 18, 32, 56))]
#0 [localhost:8000] Thank you for supporting Pro Web 2.0 Mashups at Unglue.it!
#1 [localhost:8000] Thanks to you, the campaign for Pro Web 2.0 Mashups has succeeded!
#2 Stripe Customer (id cus_0ji1hFS8xLluuZ; description: RaymondYee) created
#3 [localhost:8000] Thank you for supporting Moby Dick at Unglue.it!
#4 [localhost:8000] Someone new has wished for your work at Unglue.it
#5 [localhost:8000] Thanks to you, the campaign for Moby Dick has succeeded! However, your credit card charge failed.
#6 Stripe Customer (id cus_0ji2Cmu6sXKBCi; description: dataunbound) created
#7 [localhost:8000] Thanks to you, the campaign for Moby Dick has succeeded!
#8 Stripe Customer (id cus_0ji24dPDiFGWU2; description: dataunbound) created

View File

@ -326,6 +326,8 @@ class PaymentManager( object ):
# only allow active transactions to go through again, if there is an error, intervention is needed
transactions = Transaction.objects.filter(campaign=campaign, status=TRANSACTION_STATUS_ACTIVE)
results = []
for t in transactions:
#
# Currently receivers are only used for paypal, so it is OK to leave the paypal info here
@ -333,14 +335,14 @@ class PaymentManager( object ):
receiver_list = [{'email':settings.PAYPAL_GLUEJAR_EMAIL, 'amount':t.amount},
{'email':campaign.paypal_receiver, 'amount':D(t.amount) * (D('1.00') - D(str(settings.GLUEJAR_COMMISSION)))}]
self.execute_transaction(t, receiver_list)
try:
self.execute_transaction(t, receiver_list)
except Exception as e:
results.append((t, e))
else:
results.append((t, None))
# TO DO: update campaign status
# Should this be done first before executing the transactions?
# How does the success/failure of transactions affect states of campaigns
return transactions
return results
def finish_campaign(self, campaign):
'''
@ -482,8 +484,6 @@ class PaymentManager( object ):
if p.success() and not p.error():
transaction.pay_key = p.key()
transaction.save()
logger.info("execute_transaction Success")
transaction_charged.send(sender=self, transaction=transaction)
return True
else:

View File

@ -1,12 +1,16 @@
from django.db import models
from django.contrib.auth.models import User
from django.conf import settings
from django.db.models import Q
from regluit.payment.parameters import *
from regluit.payment.signals import credit_balance_added, pledge_created
from regluit.utils.localdatetime import now
from django.db.models.signals import post_save, post_delete
from decimal import Decimal
from datetime import timedelta
import datetime
import uuid
import urllib
import logging
@ -56,7 +60,7 @@ class Transaction(models.Model):
# whether a Preapproval has been approved or not
approved = models.NullBooleanField(null=True)
# error message from a PayPal transaction
# error message from a transaction
error = models.CharField(max_length=256, null=True)
# IPN.reason_code
@ -136,7 +140,7 @@ class Transaction(models.Model):
self.approved=True
now_val = now()
self.date_authorized = now_val
self.date_expired = now_val + timedelta( days=settings.PREAPPROVAL_PERIOD )
self.date_expired = now_val + datetime.timedelta( days=settings.PREAPPROVAL_PERIOD )
self.save()
pledge_created.send(sender=self, transaction=self)
@ -333,9 +337,6 @@ class Account(models.Model):
self.date_deactivated = now()
self.save()
from django.db.models.signals import post_save, post_delete
import regluit.payment.manager
# handle any save, updates to a payment.Transaction
def handle_transaction_change(sender, instance, created, **kwargs):
@ -355,3 +356,30 @@ def handle_transaction_delete(sender, instance, **kwargs):
post_save.connect(handle_transaction_change,sender=Transaction)
post_delete.connect(handle_transaction_delete,sender=Transaction)
# handle recharging failed transactions
def recharge_failed_transactions(sender, created, instance, **kwargs):
"""When a new Account is saved, check whether this is the new active account for a user. If so, recharge any
outstanding failed transactions
"""
# make sure the new account is active
if instance.date_deactivated is not None:
return False
transactions_to_recharge = instance.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.campaign.deadline) < datetime.timedelta(settings.RECHARGE_WINDOW):
logger.info("Recharging transaction {0} w/ status {1}".format(transaction.id, transaction.status))
pm.execute_transaction(transaction, [])
post_save.connect(recharge_failed_transactions, sender=Account)

View File

@ -48,3 +48,5 @@ TRANSACTION_STATUS_REFUNDED = 'Refunded'
# The transaction was refused/denied
TRANSACTION_STATUS_FAILED = 'Failed'
# Transaction written off -- unable to successfully be charged after campaign succeeded
TRANSACTION_STATUS_WRITTEN_OFF = 'Written-Off'

View File

@ -2,6 +2,10 @@ from notification import models as notification
from django.dispatch import Signal
transaction_charged = Signal(providing_args=["transaction"])
transaction_failed = Signal(providing_args=["transaction"])
transaction_refunded = Signal(providing_args=["transaction"])
transaction_disputed = Signal(providing_args=["transaction"])
pledge_created = Signal(providing_args=["transaction"])
pledge_modified = Signal(providing_args=["transaction", "up_or_down"])
credit_balance_added = Signal(providing_args=["amount"])
@ -14,14 +18,16 @@ from django.contrib.auth.models import User
# create Credit to associate with User
def create_user_objects(sender, created, instance, **kwargs):
# use get_model to avoid circular import problem with models
try:
Credit = get_model('payment', 'Credit')
if created:
Credit.objects.create(user=instance)
except DatabaseError:
# this can happen when creating superuser during syncdb since the
# core_wishlist table doesn't exist yet
return
# don't create Credit if we are loading fixtures http://stackoverflow.com/a/3500009/7782
if not kwargs.get('raw', False):
try:
Credit = get_model('payment', 'Credit')
if created:
Credit.objects.create(user=instance)
except DatabaseError:
# this can happen when creating superuser during syncdb since the
# core_wishlist table doesn't exist yet
return
post_save.connect(create_user_objects, sender=User)

View File

@ -2,21 +2,53 @@
# https://stripe.com/docs/api?lang=python#top
import logging
import json
from datetime import datetime, timedelta
from pytz import utc
from itertools import islice
import re
from django.conf import settings
from django.http import HttpResponse
from django.core.mail import send_mail
from regluit.payment.models import Account
from regluit.payment.models import Account, Transaction, PaymentResponse
from regluit.payment.parameters import PAYMENT_HOST_STRIPE
from regluit.payment.parameters import TRANSACTION_STATUS_ACTIVE, TRANSACTION_STATUS_COMPLETE, PAYMENT_TYPE_AUTHORIZATION, TRANSACTION_STATUS_CANCELED
from regluit.payment.parameters import TRANSACTION_STATUS_ACTIVE, TRANSACTION_STATUS_COMPLETE, TRANSACTION_STATUS_ERROR, PAYMENT_TYPE_AUTHORIZATION, TRANSACTION_STATUS_CANCELED
from regluit.payment import baseprocessor
from regluit.payment.signals import transaction_charged, transaction_failed
from regluit.utils.localdatetime import now, zuluformat
import stripe
# as of 2012.11.05
STRIPE_EVENT_TYPES = ['account.updated', 'account.application.deauthorized',
'charge.succeeded', 'charge.failed', 'charge.refunded', 'charge.disputed',
'customer.created', 'customer.updated', 'customer.deleted',
'customer.subscription.created', 'customer.subscription.updated',
'customer.subscription.deleted', 'customer.subscription.trial_will_end',
'customer.discount.created', 'customer.discount.updated', 'customer.discount.deleted',
'invoice.created', 'invoice.updated', 'invoice.payment_succeeded', 'invoice.payment_failed',
'invoiceitem.created', 'invoiceitem.updated', 'invoiceitem.deleted',
'plan.created', 'plan.updated', 'plan.deleted',
'coupon.created', 'coupon.updated', 'coupon.deleted',
'transfer.created', 'transfer.updated', 'transfer.failed',
'ping']
logger = logging.getLogger(__name__)
# http://stackoverflow.com/questions/2348317/how-to-write-a-pager-for-python-iterators/2350904#2350904
def grouper(iterable, page_size):
page= []
for item in iterable:
page.append( item )
if len(page) == page_size:
yield page
page= []
if len(page):
yield page
class StripelibError(baseprocessor.ProcessorError):
pass
@ -35,6 +67,9 @@ except:
# moving towards not having the stripe api key for the non profit partner in the unglue.it code -- but in a logically
# distinct application
TEST_STRIPE_PK = 'pk_0EajXPn195ZdF7Gt7pCxsqRhNN5BF'
TEST_STRIPE_SK = 'sk_0EajIO4Dnh646KPIgLWGcO10f9qnH'
try:
from regluit.core.models import Key
STRIPE_PK = Key.objects.get(name="STRIPE_PK").value
@ -43,8 +78,8 @@ try:
except Exception, e:
# currently test keys for Gluejar and for raymond.yee@gmail.com as standin for non-profit
logger.info('Exception {0} Need to use TEST STRIPE_*_KEYs'.format(e))
STRIPE_PK = 'pk_0EajXPn195ZdF7Gt7pCxsqRhNN5BF'
STRIPE_SK = 'sk_0EajIO4Dnh646KPIgLWGcO10f9qnH'
STRIPE_PK = TEST_STRIPE_PK
STRIPE_SK = TEST_STRIPE_SK
# set default stripe api_key to that of unglue.it
@ -132,6 +167,12 @@ def card (number=TEST_CARDS[0][0], exp_month=1, exp_year=2020, cvc=None, name=No
return filter_none(card)
def _isListableAPIResource(x):
"""test whether x is an instance of the stripe.ListableAPIResource class"""
try:
return issubclass(x, stripe.ListableAPIResource)
except:
return False
class StripeClient(object):
def __init__(self, api_key=STRIPE_SK):
@ -159,7 +200,6 @@ class StripeClient(object):
def event(self):
return stripe.Event(api_key=self.api_key)
def create_token(self, card):
return stripe.Token(api_key=self.api_key).create(card=card)
@ -200,13 +240,60 @@ class StripeClient(object):
# https://stripe.com/docs/api?lang=python#refund_charge
ch = stripe.Charge(api_key=self.api_key).retrieve(charge_id)
ch.refund()
return ch
return ch
def _all_objs(self, class_type, **kwargs):
"""a generic iterator for all classes of type stripe.ListableAPIResource"""
# type=None, created=None, count=None, offset=0
# obj_type: one of 'Charge','Coupon','Customer', 'Event','Invoice', 'InvoiceItem', 'Plan', 'Transfer'
def list_all_charges(self, count=None, offset=None, customer=None):
# https://stripe.com/docs/api?lang=python#list_charges
return stripe.Charge(api_key=self.api_key).all(count=count, offset=offset, customer=customer)
try:
stripe_class = getattr(stripe, class_type)
except:
yield StopIteration
else:
if _isListableAPIResource(stripe_class):
kwargs2 = kwargs.copy()
kwargs2.setdefault('offset', 0)
kwargs2.setdefault('count', 100)
more_items = True
while more_items:
items = stripe_class(api_key=self.api_key).all(**kwargs2)['data']
for item in items:
yield item
if len(items):
kwargs2['offset'] += len(items)
else:
more_items = False
else:
yield StopIteration
def __getattribute__(self, name):
""" handle list_* calls"""
mapping = {'list_charges':"Charge",
'list_coupons': "Coupon",
'list_customers':"Customer",
'list_events':"Event",
'list_invoices':"Invoice",
'list_invoiceitems':"InvoiceItem",
'list_plans':"Plan",
'list_transfers':"Transfer"
}
if name in mapping.keys():
class_value = mapping[name]
def list_events(**kwargs):
for e in self._all_objs(class_value, **kwargs):
yield e
return list_events
else:
return object.__getattribute__(self, name)
# can't test Transfer in test mode: "There are no transfers in test mode."
#pledge scenario
@ -254,6 +341,7 @@ class StripeClient(object):
# * expired_card -- also not easily simulatable in test mode
class StripeErrorTest(TestCase):
"""Make sure the exceptions returned by stripe act as expected"""
@ -305,7 +393,7 @@ class StripeErrorTest(TestCase):
self.fail("Attempt to create customer did not throw expected exception.")
except stripe.CardError as e:
self.assertEqual(e.code, "card_declined")
self.assertEqual(e.message, "Your card was declined.")
self.assertEqual(e.message, "Your card was declined")
def test_charge_bad_cust(self):
# expect the card to be declined -- and for us to get CardError
@ -389,8 +477,7 @@ class StripeErrorTest(TestCase):
except stripe.CardError as e:
self.assertEqual(e.code, "missing")
self.assertEqual(e.message, "Cannot charge a customer that has no active card")
class PledgeScenarioTest(TestCase):
@classmethod
@ -475,9 +562,6 @@ class Processor(baseprocessor.Processor):
user.profile.account.deactivate()
account.save()
return account
class Pay(StripePaymentRequest, baseprocessor.Processor.Pay):
pass
class Preapproval(StripePaymentRequest, baseprocessor.Processor.Preapproval):
@ -535,40 +619,58 @@ class Processor(baseprocessor.Processor):
class Execute(StripePaymentRequest):
'''
The Execute function sends an existing token(generated via the URL from the pay operation), and collects
the money.
The Execute function attempts to charge the credit card of stripe Customer associated with user connected to transaction
'''
def __init__(self, transaction=None):
self.transaction = transaction
# execute transaction
assert transaction.host == PAYMENT_HOST_STRIPE
# make sure we are dealing with a stripe transaction
if transaction.host <> PAYMENT_HOST_STRIPE:
raise StripeLibError("transaction.host {0} is not the expected {1}".format(transaction.host, PAYMENT_HOST_STRIPE))
sc = StripeClient()
# look at transaction.preapproval_key
# is it a customer or a token?
# look first for transaction.user.profile.account.account_id
try:
customer_id = transaction.user.profile.account.account_id
except:
customer_id = transaction.preapproval_key
# BUGBUG: replace description with somethin more useful
# TO DO: rewrapping StripeError to StripelibError -- but maybe we should be more specific
if transaction.preapproval_key.startswith('cus_'):
if customer_id is not None:
try:
charge = sc.create_charge(transaction.amount, customer=transaction.preapproval_key, description="${0} for test / retain cc".format(transaction.amount))
except stripe.StripeError as e:
raise StripelibError(e.message, e)
elif transaction.preapproval_key.startswith('tok_'):
try:
charge = sc.create_charge(transaction.amount, card=transaction.preapproval_key, description="${0} for test / cc not retained".format(transaction.amount))
# useful things to put in description: transaction.id, transaction.user.id, customer_id, transaction.amount
charge = sc.create_charge(transaction.amount, customer=customer_id,
description=json.dumps({"t.id":transaction.id,
"email":transaction.user.email,
"cus.id":customer_id,
"tc.id": transaction.campaign.id,
"amount": float(transaction.amount)}))
except stripe.StripeError as e:
# what to record in terms of errors? (error log?)
# use PaymentResponse to store error
r = PaymentResponse.objects.create(api="stripelib.Execute", correlation_id=None,
timestamp=now(), info=e.message,
status=TRANSACTION_STATUS_ERROR, transaction=transaction)
transaction.status = TRANSACTION_STATUS_ERROR
transaction.error = e.message
transaction.save()
raise StripelibError(e.message, e)
transaction.status = TRANSACTION_STATUS_COMPLETE
transaction.pay_key = charge.id
transaction.date_payment = now()
transaction.save()
self.charge = charge
else:
self.charge = charge
transaction.status = TRANSACTION_STATUS_COMPLETE
transaction.pay_key = charge.id
transaction.date_payment = now()
transaction.save()
else:
# nothing to charge
raise StripeLibError("No customer id available to charge for transaction {0}".format(transaction.id), None)
def api(self):
return "Base Pay"
@ -602,8 +704,111 @@ class Processor(baseprocessor.Processor):
# Set the other fields that are expected. We don't have values for these now, so just copy the transaction
self.currency = transaction.currency
self.amount = transaction.amount
def ProcessIPN(self, request):
return HttpResponse("hello from Stripe IPN")
# retrieve the request's body and parse it as JSON in, e.g. Django
try:
event_json = json.loads(request.body)
except ValueError, e:
# not able to parse request.body -- throw a "Bad Request" error
logger.warning("Non-json being sent to Stripe IPN: {0}".format(e))
return HttpResponse(status=400)
else:
# now parse out pieces of the webhook
event_id = event_json.get("id")
# use Stripe to ask for details -- ignore what we're sent for security
sc = StripeClient()
try:
event = sc.event.retrieve(event_id)
except stripe.InvalidRequestError:
logger.warning("Invalid Event ID: {0}".format(event_id))
return HttpResponse(status=400)
else:
event_type = event.get("type")
if event_type not in STRIPE_EVENT_TYPES:
logger.warning("Unrecognized Stripe event type {0} for event {1}".format(event_type, event_id))
# is this the right code to respond with?
return HttpResponse(status=400)
# https://stripe.com/docs/api?lang=python#event_types -- type to delegate things
# parse out type as resource.action
try:
(resource, action) = re.match("^(.+)\.([^\.]*)$", event_type).groups()
except Exception, e:
logger.warning("Parsing of event_type into resource, action failed: {0}".format(e))
return HttpResponse(status=400)
try:
ev_object = event.data.object
except Exception, e:
logger.warning("attempt to retrieve event object failed: {0}".format(e))
return HttpResponse(status=400)
if event_type == 'account.updated':
# should we alert ourselves?
# how about account.application.deauthorized ?
pass
elif resource == 'charge':
# we need to handle: succeeded, failed, refunded, disputed
if action == 'succeeded':
logger.info("charge.succeeded webhook for {0}".format(ev_object.get("id")))
# try to parse description of object to pull related transaction if any
# wrapping this in a try statement because it possible that we have a charge.succeeded outside context of unglue.it
try:
charge_meta = json.loads(ev_object["description"])
transaction = Transaction.objects.get(id=charge_meta["t.id"])
# now check that account associated with the transaction matches
# ev.data.object.id, t.pay_key
if ev_object.id == transaction.pay_key:
logger.info("ev_object.id == transaction.pay_key: {0}".format(ev_object.id))
else:
logger.warning("ev_object.id {0} <> transaction.pay_key {1}".format(ev_object.id, transaction.pay_key))
# now -- should fire off transaction_charged here -- if so we need to move it from ?
transaction_charged.send(sender=self, transaction=transaction)
except Exception, e:
logger.warning(e)
elif action == 'failed':
logger.info("charge.failed webhook for {0}".format(ev_object.get("id")))
try:
charge_meta = json.loads(ev_object["description"])
transaction = Transaction.objects.get(id=charge_meta["t.id"])
# now check that account associated with the transaction matches
# ev.data.object.id, t.pay_key
if ev_object.id == transaction.pay_key:
logger.info("ev_object.id == transaction.pay_key: {0}".format(ev_object.id))
else:
logger.warning("ev_object.id {0} <> transaction.pay_key {1}".format(ev_object.id, transaction.pay_key))
# now -- should fire off transaction_charged here -- if so we need to move it from ?
transaction_failed.send(sender=self, transaction=transaction)
except Exception, e:
logger.warning(e)
elif action == 'refunded':
pass
elif action == 'disputed':
pass
else:
# unexpected
pass
elif resource == 'customer':
if action == 'created':
# test application: email Raymond
# do we have a flag to indicate production vs non-production? -- or does it matter?
# email RY whenever a new Customer created -- we probably want to replace this with some other
# more useful long tem action.
send_mail("Stripe Customer (id {0}; description: {1}) created".format(ev_object.get("id"), ev_object.get("description")),
"Stripe Customer email: {0}".format(ev_object.get("email")),
"notices@gluejar.com",
["rdhyee@gluejar.com"])
logger.info("email sent for customer.created for {0}".format(ev_object.get("id")))
# handle updated, deleted
else:
pass
else: # other events
pass
return HttpResponse("event_id: {0} event_type: {1}".format(event_id, event_type))
def suite():

View File

@ -323,8 +323,6 @@ class CreditTest(TestCase):
self.assertEqual(self.user1.credit.balance, 0)
self.assertEqual(self.user2.credit.balance, 50)
class TransactionTest(TestCase):
def setUp(self):
"""
@ -387,7 +385,6 @@ class BasicGuiTest(TestCase):
def tearDown(self):
self.selenium.quit()
class AccountTest(TestCase):
def setUp(self):

View File

@ -301,14 +301,11 @@ def runTests(request):
@csrf_exempt
def handleIPN(request, module):
# Handler for paypal IPN notifications
# Handler for IPN /webhook notifications
p = PaymentManager()
p.processIPN(request, module)
logger.info(str(request.POST))
return HttpResponse("ipn")
return p.processIPN(request, module)
def paymentcomplete(request):
# pick up all get and post parameters and display

View File

@ -4,6 +4,8 @@ MySQL-python==1.2.3
Pillow==1.7.7
Pyzotero==0.9.51
South==0.7.6
WebOb==1.2.3
WebTest==1.4.0
amqplib==1.0.2
anyjson==0.3.3
billiard==2.7.3.12

View File

@ -226,6 +226,9 @@ GLUEJAR_COMMISSION = 0.06
PREAPPROVAL_PERIOD = 365 # days to ask for in a preapproval
PREAPPROVAL_PERIOD_AFTER_CAMPAIGN = 90 # if we ask for preapproval time after a campaign deadline
# How many days we will try to collect on failed transactions until they are written off
RECHARGE_WINDOW = 7
GOODREADS_API_KEY = ""
GOODREADS_API_SECRET = ""

View File

@ -89,8 +89,12 @@ def instance_metrics(instance):
for metric in metrics:
if 'InstanceId' in metric.dimensions:
if instance.id in metric.dimensions['InstanceId']:
my_metrics.append(metric)
# instance.id not guaranteed to be there
try:
if instance.id in metric.dimensions['InstanceId']:
my_metrics.append(metric)
except:
pass
return my_metrics
@ -346,4 +350,5 @@ if __name__ == '__main__':
pprint (stats_for_instances(all_instances()))
web1 = instance('web1')
print instance_metrics(web1)
print all_images()

View File

@ -3,5 +3,5 @@
django-admin.py syncdb --migrate <<'EOF'
no
EOF
django-admin.py loaddata ../test/fixtures/basic_campaign_test.json
django-admin.py loaddata ../core/fixtures/basic_campaign_test.json