use getrefundstatus if transaction is marked pending refund (bug 821900)

This commit is contained in:
Kevin Ngo 2013-02-06 20:07:21 -08:00 коммит произвёл Kevin Ngo
Родитель dffb4aeac2
Коммит 656428682f
7 изменённых файлов: 198 добавлений и 141 удалений

Просмотреть файл

@ -373,13 +373,10 @@ class Contribution(amo.models.ModelBase):
limit = datetime.timedelta(seconds=period)
return datetime.datetime.now() < (self.created + limit)
def has_refund(self):
from market.models import Refund
return (Refund.objects.filter(
contribution=self, status__in=[amo.REFUND_PENDING,
amo.REFUND_APPROVED,
amo.REFUND_APPROVED_INSTANT])
.exists())
def get_refund_contribs(self):
"""Get related set of refund contributions."""
return Contribution.objects.filter(
related=self, type=amo.CONTRIB_REFUND).order_by('-modified')
def is_refunded(self):
"""

Просмотреть файл

@ -330,7 +330,7 @@ h1 {
.refund-transaction {
float: left;
padding-top: 13px;
padding-top: 15px;
width: 50%;
label {
display: block;

Просмотреть файл

@ -1,24 +1,11 @@
from tower import ugettext_lazy as _lazy
from tower import ugettext as _
STATUS_PENDING = 0 # When the payment has been started.
STATUS_COMPLETED = 1 # When the IPN says its ok.
STATUS_CHECKED = 2 # When someone calls pay-check on the transaction.
# When we we've got a request for a payment, but more work needs to be done
# before we can proceed to the next stage, pending.
STATUS_RECEIVED = 3
# Something went wrong and this transaction failed completely.
STATUS_FAILED = 4
# Explicit cancel action.
STATUS_CANCELLED = 5
STATUS_DEFAULT = STATUS_PENDING
PROVIDER_PAYPAL = 0
PROVIDER_BANGO = 1
PROVIDERS = {
PROVIDER_PAYPAL: _lazy('PayPal'),
PROVIDER_BANGO: _lazy('Bango'),
PENDING = 'PENDING'
COMPLETED = 'OK'
FAILED = 'FAILED'
REFUND_STATUSES = {
PENDING: _('Pending'),
COMPLETED: _('Completed'),
FAILED: _('Failed'),
}

Просмотреть файл

@ -27,7 +27,6 @@
<dd>
<a href="{{ app.get_detail_url() }}">{{ app.name }}</a>
</dd>
<dt>{{ _('Provider') }}</dt><dd>{{ provider }}</dd>
<dt>{{ _('Price Tier') }}</dt><dd>{{ contrib.price_tier }}</dd>
{% if related %}
<dt>{{ _('Related Transaction') }}</dt>
@ -43,13 +42,19 @@
{{ no_results() }}
{% endif %}
{% if action_allowed('Transaction', 'Refund') and is_refundable %}
<form class="refund-transaction c" method="post"
action="{{ url('lookup.transaction_refund', uuid) }}">
{{ csrf() }}
{{ tx_refund_form }}
<button>{{ _('Refund this transaction') }}</button>
</form>
{% endif %}
<div class="refund-transaction c">
<dl>
<dt>{{ _('Refund Status') }}</dt>
<dd>{{ refund_status or _('Unrequested') }}</dd>
</dl>
{% if action_allowed('Transaction', 'Refund') and is_refundable %}
<form method="post"
action="{{ url('lookup.transaction_refund', uuid) }}">
{{ csrf() }}
{{ tx_refund_form }}
<button>{{ _('Refund this transaction') }}</button>
</form>
{% endif %}
</div>
{% endblock %}

Просмотреть файл

@ -6,7 +6,6 @@ from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.messages.storage.fallback import FallbackStorage
from django.core import mail
from django.core.exceptions import ObjectDoesNotExist
from django.test.client import RequestFactory
from django.utils.encoding import smart_str
@ -26,8 +25,8 @@ from amo.tests import addon_factory, app_factory, ESTestCase, TestCase
from amo.urlresolvers import reverse
from devhub.models import ActivityLog
from market.models import AddonPaymentData, AddonPremium, Price, Refund
from mkt.constants.payments import (STATUS_COMPLETED, STATUS_FAILED,
STATUS_PENDING)
from mkt.constants.payments import (COMPLETED, FAILED, PENDING,
REFUND_STATUSES)
from mkt.lookup.views import _transaction_summary, transaction_refund
from mkt.site.fixtures import fixture
from mkt.webapps.cron import update_weekly_downloads
@ -279,21 +278,55 @@ class TestTransactionSummary(TestCase):
fixtures = fixture('user_support_staff', 'user_999')
def setUp(self):
self.uuid = 45
self.transaction_id = 999
self.buyer_uuid = 123
self.uuid = 'some:uuid'
self.transaction_id = 'some:tr'
self.seller_uuid = 456
self.related_tx_uuid = 789
self.user = UserProfile.objects.get(pk=999)
self.app = addon_factory(type=amo.ADDON_WEBAPP)
self.contrib = Contribution.objects.create(
addon=addon_factory(type=amo.ADDON_WEBAPP), uuid=self.uuid,
user=self.user, transaction_id='some:tr')
addon=self.app, uuid=self.uuid, user=self.user,
transaction_id=self.transaction_id)
self.url = reverse('lookup.transaction_summary', args=[self.uuid])
self.client.login(username='support-staff@mozilla.com',
password='password')
def create_test_refund(self):
refund_contrib = Contribution.objects.create(
addon=self.app, related=self.contrib, type=amo.CONTRIB_REFUND,
transaction_id='testtransactionid')
refund_contrib.enqueue_refund(amo.REFUND_PENDING, self.user)
def test_transaction_summary(self):
data = _transaction_summary(self.uuid)
eq_(data['is_refundable'], False)
eq_(data['contrib'].pk, self.contrib.pk)
@mock.patch('mkt.lookup.views.client')
def test_refund_status(self, solitude):
solitude.api.bango.refund.status.get.return_value = {'status': PENDING}
self.create_test_refund()
data = _transaction_summary(self.uuid)
eq_(data['refund_status'], REFUND_STATUSES[PENDING])
@mock.patch('mkt.lookup.views.client')
def test_is_refundable(self, solitude):
solitude.api.bango.refund.status.get.return_value = {'status': PENDING}
self.contrib.update(type=amo.CONTRIB_PURCHASE)
data = _transaction_summary(self.uuid)
eq_(data['contrib'].pk, self.contrib.pk)
eq_(data['is_refundable'], True)
self.create_test_refund()
data = _transaction_summary(self.uuid)
eq_(data['is_refundable'], False)
@mock.patch('mkt.lookup.views.client')
def test_200(self, solitude):
r = self.client.get(self.url)
@ -305,32 +338,18 @@ class TestTransactionSummary(TestCase):
r = self.client.get(self.url)
eq_(r.status_code, 403)
@mock.patch('mkt.lookup.views.client')
def test_no_transaction_404(self, solitude):
solitude.api.generic.transaction.get_object_or_404.side_effect = ObjectDoesNotExist
def test_no_transaction_404(self):
r = self.client.get(reverse('lookup.transaction_summary', args=[999]))
eq_(r.status_code, 404)
@mock.patch('mkt.lookup.views.client')
def test_transaction_summary(self, solitude):
data = _transaction_summary(self.uuid)
eq_(data['contrib'].pk, self.contrib.pk)
eq_(data['is_refundable'], False)
@mock.patch('mkt.lookup.views.client')
def test_is_refundable(self, solitude):
self.contrib.update(type=amo.CONTRIB_PURCHASE)
data = _transaction_summary(self.uuid)
eq_(data['contrib'].pk, self.contrib.pk)
eq_(data['is_refundable'], True)
@mock.patch.object(settings, 'TASK_USER_ID', 999)
class TestTransactionRefund(TestCase):
fixtures = fixture('user_support_staff', 'user_999')
def setUp(self):
self.uuid = 45
self.uuid = 'paymentuuid'
self.refund_uuid = 'refunduuid'
self.summary_url = reverse('lookup.transaction_summary',
args=[self.uuid])
self.url = reverse('lookup.transaction_refund', args=[self.uuid])
@ -340,7 +359,7 @@ class TestTransactionRefund(TestCase):
self.contrib = Contribution.objects.create(
addon=self.app, user=self.user, uuid=self.uuid,
type=amo.CONTRIB_PURCHASE)
type=amo.CONTRIB_PURCHASE, amount=1)
self.req = RequestFactory().post(self.url, {'refund_reason': 'text'})
self.req.user = User.objects.get(username='support_staff')
@ -352,40 +371,91 @@ class TestTransactionRefund(TestCase):
setattr(self.req, '_messages', messages)
self.login(self.req.user)
def bango_ret(self, status):
return {
'status': status,
'transaction': 'transaction_uri'
}
def refund_tx_ret(self):
return {'uuid': self.refund_uuid}
@mock.patch('mkt.lookup.views.client')
def test_refund_success(self, solitude):
solitude.api.bango.refund.post.return_value = ({
'status': STATUS_PENDING})
solitude.api.bango.refund.post.return_value = self.bango_ret(PENDING)
solitude.get.return_value = self.refund_tx_ret()
# Do refund.
res = transaction_refund(self.req, self.uuid)
refund = Refund.objects.filter(contribution__addon=self.app)
eq_(refund.count(), 1)
refund_contribs = self.contrib.get_refund_contribs()
# Check Refund created.
assert refund.exists()
eq_(refund[0].status, amo.REFUND_PENDING)
assert self.req.POST['refund_reason'] in refund[0].refund_reason
# Check refund Contribution created.
eq_(refund_contribs.exists(), True)
eq_(refund_contribs[0].refund, refund[0])
eq_(refund_contribs[0].related, self.contrib)
eq_(refund_contribs[0].amount, -self.contrib.amount)
self.assert3xx(res, self.summary_url)
@mock.patch('mkt.lookup.views.client')
def test_refund_failed(self, solitude):
solitude.api.bango.refund.post.return_value = (
{'status': STATUS_FAILED})
solitude.api.bango.refund.post.return_value = self.bango_ret(FAILED)
res = transaction_refund(self.req, self.uuid)
eq_(self.contrib.has_refund(), False)
# Check no refund Contributions created.
assert not self.contrib.get_refund_contribs().exists()
self.assert3xx(res, self.summary_url)
def test_cant_refund(self):
self.contrib.update(type=amo.CONTRIB_PENDING)
resp = self.client.post(self.url, {'refund_reason': 'text'})
eq_(resp.status_code, 404)
@mock.patch('mkt.lookup.views.client')
def test_already_refunded(self, solitude):
solitude.api.bango.refund.post.return_value = self.bango_ret(PENDING)
solitude.get.return_value = self.refund_tx_ret()
res = transaction_refund(self.req, self.uuid)
refund_count = Contribution.objects.all().count()
# Check no refund Contributions created.
res = self.client.post(self.url, {'refund_reason': 'text'})
assert refund_count == Contribution.objects.all().count()
self.assert3xx(res, reverse('lookup.transaction_summary',
args=[self.uuid]))
@mock.patch('mkt.lookup.views.client')
def test_refund_slumber_error(self, solitude):
for exception in (exceptions.HttpClientError,
exceptions.HttpServerError):
solitude.api.bango.refund.post.side_effect = exception
res = transaction_refund(self.req, self.uuid)
eq_(self.contrib.has_refund(), False)
# Check no refund Contributions created.
assert not self.contrib.get_refund_contribs().exists()
self.assert3xx(res, self.summary_url)
@mock.patch('mkt.lookup.views.client')
def test_redirect(self, solitude):
solitude.api.bango.refund.post.return_value = self.bango_ret(PENDING)
solitude.get.return_value = self.refund_tx_ret()
res = self.client.post(self.url, {'refund_reason': 'text'})
self.assert3xx(res, reverse('lookup.transaction_summary',
args=[self.uuid]))
@mock.patch('mkt.lookup.views.client')
@mock.patch.object(settings, 'SEND_REAL_EMAIL', True)
def test_refund_pending_email(self, solitude):
solitude.api.bango.refund.post.return_value = (
{'status': STATUS_PENDING})
solitude.api.bango.refund.post.return_value = self.bango_ret(PENDING)
solitude.get.return_value = self.refund_tx_ret()
transaction_refund(self.req, self.uuid)
eq_(len(mail.outbox), 1)
@ -394,39 +464,18 @@ class TestTransactionRefund(TestCase):
@mock.patch('mkt.lookup.views.client')
@mock.patch.object(settings, 'SEND_REAL_EMAIL', True)
def test_refund_completed_email(self, solitude):
solitude.api.bango.refund.post.return_value = (
{'status': STATUS_COMPLETED})
solitude.api.bango.refund.post.return_value = self.bango_ret(COMPLETED)
solitude.get.return_value = self.refund_tx_ret()
transaction_refund(self.req, self.uuid)
eq_(len(mail.outbox), 1)
assert self.app.name.localized_string in smart_str(mail.outbox[0].body)
@mock.patch('mkt.lookup.views.client')
def test_redirect(self, solitude):
solitude.api.bango.refund.post.return_value = (
{'status': STATUS_PENDING})
res = self.client.post(self.url, {'refund_reason': 'text'})
self.assert3xx(res, reverse('lookup.transaction_summary',
args=[self.uuid]))
def test_cant_refund(self):
self.contrib.update(type=amo.CONTRIB_PENDING)
resp = self.client.post(self.url, {'refund_reason': 'text'})
eq_(resp.status_code, 404)
def test_already_refunded(self):
self.contrib.enqueue_refund(status=amo.REFUND_PENDING,
refund_reason='front fell off',
user=self.user)
res = self.client.post(self.url, {'refund_reason': 'text'})
self.assert3xx(res, reverse('lookup.transaction_summary',
args=[self.uuid]))
@mock.patch('mkt.lookup.views.client')
def test_403_reg_user(self, solitude):
solitude.api.bango.refund.post.return_value = (
{'status': STATUS_PENDING})
solitude.api.bango.refund.post.return_value = self.bango_ret(PENDING)
solitude.get.return_value = self.refund_tx_ret()
self.login(self.user)
res = self.client.post(self.url, {'refund_reason': 'text'})
eq_(res.status_code, 403)

Просмотреть файл

@ -36,6 +36,6 @@ urlpatterns = patterns('',
url(r'^app_search\.json$', views.app_search,
name='lookup.app_search'),
(r'^app/(?P<addon_id>[^/]+)/', include(app_patterns)),
(r'^transaction/(?P<uuid>[^/]+)/', include(transaction_patterns)),
(r'^transaction/(?P<tx_uuid>[^/]+)/', include(transaction_patterns)),
(r'^user/(?P<user_id>[^/]+)/', include(user_patterns)),
)

Просмотреть файл

@ -1,4 +1,6 @@
from datetime import datetime, timedelta
import hashlib
import uuid
from django.db import connection
from django.db.models import Sum, Count, Q
@ -23,8 +25,8 @@ from devhub.models import ActivityLog
from elasticutils.contrib.django import S
from lib.pay_server import client
from market.models import AddonPaymentData, Refund
from mkt.constants.payments import (STATUS_COMPLETED, STATUS_FAILED,
STATUS_PENDING, PROVIDERS)
from mkt.constants.payments import (COMPLETED, FAILED, PENDING,
REFUND_STATUSES)
from mkt.account.utils import purchase_list
from mkt.lookup.forms import TransactionRefundForm, TransactionSearchForm
from mkt.lookup.tasks import (email_buyer_refund_approved,
@ -82,8 +84,8 @@ def user_summary(request, user_id):
@login_required
@permission_required('Transaction', 'View')
def transaction_summary(request, uuid):
tx_data = _transaction_summary(uuid)
def transaction_summary(request, tx_uuid):
tx_data = _transaction_summary(tx_uuid)
if not tx_data:
raise Http404
@ -91,88 +93,105 @@ def transaction_summary(request, uuid):
tx_refund_form = TransactionRefundForm()
return jingo.render(request, 'lookup/transaction_summary.html',
dict({'uuid': uuid, 'tx_form': tx_form,
dict({'uuid': tx_uuid, 'tx_form': tx_form,
'tx_refund_form': tx_refund_form}.items() +
tx_data.items()))
def _transaction_summary(uuid):
def _transaction_summary(tx_uuid):
"""Get transaction details from Solitude API."""
contrib = get_object_or_404(Contribution, uuid=uuid)
contrib = get_object_or_404(Contribution, uuid=tx_uuid)
refund_contribs = contrib.get_refund_contribs()
refund_contrib = refund_contribs[0] if refund_contribs.exists() else None
# If the transaction is pending, we haven't assigned a transaction
# uuid to the form yet.
solitude = {}
if contrib.transaction_id:
# We'll probably be pulling more from this later.
solitude = (client.api.generic.transaction
.get_object_or_404(uuid=contrib.transaction_id))
# Get refund status.
refund_status = None
if refund_contrib and refund_contrib.refund.status == amo.REFUND_PENDING:
try:
refund_status = REFUND_STATUSES[client.api.bango.refund.status.get(
data={'uuid': refund_contrib.transaction_id})['status']]
except HttpServerError:
refund_status = _('Currently unable to retrieve refund status.')
return {
# There won't be a provider on pending refunds.
'provider': PROVIDERS.get(solitude.get('provider', ''), 'None'),
# Solitude data.
'refund_status': refund_status,
# Zamboni data.
'app': contrib.addon,
'contrib': contrib,
'related': contrib.related,
'type': amo.CONTRIB_TYPES.get(contrib.type, _('Incomplete')),
# Whitelist what is refundable.
'is_refundable': (contrib.type == amo.CONTRIB_PURCHASE
and not contrib.is_refunded()),
'related': contrib.related,
'is_refundable': ((contrib.type == amo.CONTRIB_PURCHASE)
and not refund_contrib),
}
@post_required
@login_required
@permission_required('Transaction', 'Refund')
def transaction_refund(request, uuid):
contrib = get_object_or_404(Contribution, uuid=uuid,
def transaction_refund(request, tx_uuid):
contrib = get_object_or_404(Contribution, uuid=tx_uuid,
type=amo.CONTRIB_PURCHASE)
if contrib.has_refund():
refund_contribs = contrib.get_refund_contribs()
refund_contrib = refund_contribs[0] if refund_contribs.exists() else None
if refund_contrib:
messages.error(request, _('A refund has already been processed.'))
return redirect(reverse('lookup.transaction_summary', args=[uuid]))
return redirect(reverse('lookup.transaction_summary', args=[tx_uuid]))
form = TransactionRefundForm(request.POST)
if not form.is_valid():
messages.error(request, str(form.errors))
return redirect(reverse('lookup.transaction_summary', args=[uuid]))
return redirect(reverse('lookup.transaction_summary', args=[tx_uuid]))
try:
res = client.api.bango.refund.post({'uuid': contrib.transaction_id})
except (HttpClientError, HttpServerError):
# Either doing something not supposed to or Solitude had an issue.
log.exception('Refund error: %s' % uuid)
log.exception('Refund error: %s' % tx_uuid)
messages.error(
request,
_('You cannot make a refund request for this transaction.'))
return redirect(reverse('lookup.transaction_summary', args=[uuid]))
return redirect(reverse('lookup.transaction_summary', args=[tx_uuid]))
if res['status'] == STATUS_PENDING:
if res['status'] in [PENDING, COMPLETED]:
# Create refund Contribution by cloning the payment Contribution.
refund_contrib = Contribution.objects.get(id=contrib.id)
refund_contrib.id = None
refund_contrib.save()
refund_contrib.update(
type=amo.CONTRIB_REFUND, related=contrib,
uuid=hashlib.md5(str(uuid.uuid4())).hexdigest(),
amount=-refund_contrib.amount if refund_contrib.amount else None,
transaction_id=client.get(res['transaction'])['uuid'])
if res['status'] == PENDING:
# Create pending Refund.
contrib.enqueue_refund(
status=amo.REFUND_PENDING,
refund_reason=form.cleaned_data['refund_reason'],
user=request.amo_user)
log.info('Refund pending: %s' % uuid)
refund_contrib.enqueue_refund(
amo.REFUND_PENDING, request.amo_user,
refund_reason=form.cleaned_data['refund_reason'])
log.info('Refund pending: %s' % tx_uuid)
email_buyer_refund_pending(contrib)
messages.success(
request, _('Refund for this transaction now pending.'))
elif res['status'] == STATUS_COMPLETED:
elif res['status'] == COMPLETED:
# Create approved Refund.
contrib.enqueue_refund(
status=amo.REFUND_APPROVED,
refund_reason=form.cleaned_data['refund_reason'],
user=request.amo_user)
log.info('Refund approved: %s' % uuid)
refund_contrib.enqueue_refund(
amo.REFUND_APPROVED, request.amo_user,
refund_reason=form.cleaned_data['refund_reason'])
log.info('Refund approved: %s' % tx_uuid)
email_buyer_refund_approved(contrib)
messages.success(
request, _('Refund for this transaction successfully approved.'))
elif res['status'] == STATUS_FAILED:
elif res['status'] == FAILED:
# Bango no like.
log.error('Refund failed: %s' % uuid)
log.error('Refund failed: %s' % tx_uuid)
messages.error(
request, _('Refund request for this transaction failed.'))
return redirect(reverse('lookup.transaction_summary', args=[uuid]))
return redirect(reverse('lookup.transaction_summary', args=[tx_uuid]))
@login_required