record and list all refunds (bug 702981)

This commit is contained in:
Chris Van 2012-02-02 16:35:07 -08:00
Родитель 81e06016c9
Коммит f6954ae4e4
23 изменённых файлов: 879 добавлений и 133 удалений

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

@ -53,7 +53,7 @@ def xssafe(value):
@register.filter
def babel_datetime(t, format='medium'):
return _get_format().datetime(t, format=format)
return _get_format().datetime(t, format=format) if t else ''
@register.function

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

@ -101,3 +101,22 @@ PAYPAL_PERSONAL = {
}
PAYPAL_PERSONAL_LOOKUP = dict([(v, k) for k, v
in PAYPAL_PERSONAL.iteritems()])
REFUND_PENDING = 0 # Just to irritate you I didn't call this REFUND_REQUESTED.
REFUND_APPROVED = 1
REFUND_APPROVED_INSTANT = 2
REFUND_DECLINED = 3
REFUND_STATUSES = {
# Refund pending (purchase > 30 min ago).
REFUND_PENDING: _('Pending'),
# Approved manually by developer.
REFUND_APPROVED: _('Approved'),
# Instant refund (purchase <= 30 min ago).
REFUND_APPROVED_INSTANT: _('Approved Instantly'),
# Declined manually by developer.
REFUND_DECLINED: _('Declined'),
}

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

@ -48,11 +48,17 @@
<li>
<a href="#" class="more-actions">{{ _('More') }}</a>
<div class="more-actions-popup popup hidden">
{% set manage_urls = (
{% set manage_urls = [
(addon.get_dev_url('owner'), _('Manage Authors & License')),
(addon.get_dev_url('profile'), _('Manage Developer Profile')),
(addon.get_dev_url('payments'), _('Manage Payments')),
(addon.get_dev_url('versions'), _('Manage Status & Versions'))) %}
(addon.get_dev_url('versions'), _('Manage Status & Versions')),
] %}
{% if addon.is_premium() and waffle.switch('allow-refund') %}
{% do manage_urls.insert(3,
(addon.get_dev_url('refunds'), loc('Manage Refunds'))
) %}
{% endif %}
<ul>
{% for url, title in manage_urls %}
<li><a href="{{ url }}">{{ title }}</a></li>

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

@ -26,8 +26,7 @@
</li>
{% endif %}
<li>
{# TODO(apps): Finalize copy. #}
<a href="{{ addon.get_dev_url('owner') }}">Manage Authors</a>
<a href="{{ addon.get_dev_url('owner') }}">{{ loc('Manage Authors') }}</a>
</li>
<li>
<a href="{{ addon.get_dev_url('payments') }}">
@ -36,9 +35,15 @@
<li>
<a href="#" class="more-actions">{{ _('More') }}</a>
<div class="more-actions-popup popup hidden">
{% set manage_urls = (
{% set manage_urls = [
(addon.get_dev_url('profile'), _('Manage Developer Profile')),
(addon.get_dev_url('versions'), _('Manage Status'))) %}
(addon.get_dev_url('versions'), _('Manage Status')),
] %}
{% if addon.is_premium() and waffle.switch('allow-refund') %}
{% do manage_urls.insert(1,
(addon.get_dev_url('refunds'), loc('Manage Refunds'))
) %}
{% endif %}
<ul>
{% for url, title in manage_urls %}
<li><a href="{{ url }}">{{ title }}</a></li>

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

@ -1,12 +1,16 @@
{% set urls = (
{% set urls = [
(addon.get_dev_url(), _('Edit Listing')),
(addon.get_dev_url('owner'), loc('Manage Authors') if addon.is_webapp()
(addon.get_dev_url('owner'), loc('Manage Authors') if webapp
else _('Manage Authors & License')),
(addon.get_dev_url('profile'), _('Manage Developer Profile')),
(addon.get_dev_url('payments'), _('Manage Payments')),
(addon.get_dev_url('versions'), loc('Manage App Status') if addon.is_webapp()
(addon.get_dev_url('versions'), loc('Manage App Status') if webapp
else _('Manage Status & Versions')),
) %}
] %}
{% if addon.is_premium() and waffle.switch('allow-refund') %}
{% do urls.insert(4, (addon.get_dev_url('refunds'), loc('Manage Refunds'))) %}
{% endif %}
<section class="secondary" role="complementary">
<div class="highlight" id="edit-addon-nav">
<h3>{{ addon.name }}</h3>

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

@ -0,0 +1,221 @@
{% extends 'devhub/base.html' %}
{% set title = _('Manage Refunds') %}
{% block title %}{{ dev_page_title(title, addon) }}{% endblock %}
{% set can_edit = check_addon_ownership(request, addon) %}
{% block bodyclass %}
{{ super() }}{% if not can_edit %} no-edit{% endif %}
{% endblock %}
{% macro base_th() %}
<th>{{ _('Transaction ID') }}</th>
<th>{{ _('User') }}</th>
<th>{{ _('Price') }}</th>
<th>{{ _('Purchased') }}</th>
<th>{{ _('Refund Requested') }}</th>
{% endmacro %}
{% macro base_td(refund, amo) %}
{% set c = refund.contribution %}
<td class="long txn">{{ c.transaction_id }}</td>
<td class="user">{{ c.user|user_link }}</td>
<td class="price">{{ c.get_amount_locale() }}</td>
{# Fligtar wants relative timestamps for pending refunds only. #}
{% if refund.status == amo.REFUND_PENDING %}
<td class="long purchased-date" title="{{ refund.created|babel_datetime }}">
{{ refund.created|timesince }}
</td>
<td class="long requested-date" title="{{ refund.requested|babel_datetime }}">
{{ refund.requested|timesince }}
</td>
{% else %}
<td class="long purchased-date">
{{ refund.created|babel_datetime }}
</td>
<td class="long requested-date">
{{ refund.requested|babel_datetime }}
</td>
{% endif %}
{% endmacro %}
{% macro action_form(refund) %}
{% set c = refund.contribution %}
{# TODO: Ajaxify this. And allow mass refunds/declines with checkboxes. #}
<form method="post" action="{{ c.get_refund_url() }}">
{{ csrf() }}
<input type="hidden" name="transaction_id" value="{{ c.transaction_id }}">
<button type="submit" class="good" name="issue">
{{ _('Issue Refund') }}</button>
<button type="submit" class="bad" name="decline">
{{ _('Decline Refund') }}</button>
</form>
{% endmacro %}
{% macro approved_row(refund) %}
<tr class="refund">
{{ base_td(refund, amo) }}
<td class="long approved-date">{{ refund.approved|babel_datetime }}</td>
</tr>
{% if refund.refund_reason %}
<tr class="reason">
<td colspan="0">
<b>{{ _('Refund Reason:') }}</b>
{{ refund.refund_reason }}
</td>
</tr>
{% endif %}
{% endmacro %}
{% block content %}
<header>
{{ dev_breadcrumbs(addon, items=[(None, title)]) }}
<h2>{{ title }}</h2>
</header>
<section id="refunds" class="primary payments devhub-form" role="main">
{% if not addon.is_premium() %}
<div id="enable-payments" class="error item">
{{ _('Your app is not currently accepting payments.') }}
<p class="item-actions">
<a href="{{ addon.get_dev_url('payments') }}">
{{ _('Set up payments.') }}</a>
</p>
</div>
{% else %}
<h3>
{{ amo.REFUND_STATUSES[amo.REFUND_PENDING] }}
<em>({{ pending.paginator.count }})</em>
</h3>
<div class="item">
{% if not pending.paginator.count %}
<p id="queue-pending" class="no-results">
{{ _('No pending refunds.') }}
</p>
{% else %}
<table id="queue-pending">
<thead>
<tr>
{{ base_th() }}
<th>{{ _('Action') }}</th>
</tr>
</thead>
<tbody>
{% for refund in pending.object_list %}
<tr class="refund">
{{ base_td(refund, amo) }}
<td rowspan="2" class="action">
{{ action_form(refund) }}
</td>
</tr>
{% if refund.refund_reason %}
<tr class="reason">
<td colspan="5">
<b>{{ _('Refund Reason:') }}</b>
{{ refund.refund_reason }}
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
{{ pending|paginator }}
<h3>
{{ amo.REFUND_STATUSES[amo.REFUND_APPROVED] }}
<em>({{ approved.paginator.count }})</em>
</h3>
<div class="item">
{% if not approved.paginator.count %}
<p id="queue-approved" class="no-results">
{{ _('No approved refunds.') }}
</p>
{% else %}
<table id="queue-approved">
<thead>
<tr>
{{ base_th() }}
<th>{{ _('Approved') }}</th>
</tr>
</thead>
<tbody>
{% for refund in approved.object_list %}
{{ approved_row(refund) }}
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
{{ approved|paginator }}
<h3>
{{ amo.REFUND_STATUSES[amo.REFUND_APPROVED_INSTANT] }}
<em>({{ instant.paginator.count }})</em>
</h3>
<div class="item">
{% if not instant.paginator.count %}
<p id="queue-instant" class="no-results">
{{ loc('No instantly approved refunds.') }}
</p>
{% else %}
<table id="queue-instant">
<thead>
<tr>
{{ base_th() }}
<th>{{ _('Approved') }}</th>
</tr>
</thead>
<tbody>
{% for refund in instant.object_list %}
{{ approved_row(refund) }}
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
{{ instant|paginator }}
<h3>
{{ amo.REFUND_STATUSES[amo.REFUND_DECLINED] }}
<em>({{ declined.paginator.count }})</em>
</h3>
<div class="item">
{% if not declined.paginator.count %}
<p id="queue-declined" class="no-results">
{{ loc('No declined refunds.') }}
</p>
{% else %}
<table id="queue-declined">
<thead>
<tr>
{{ base_th() }}
<th>{{ _('Declined') }}</th>
</tr>
</thead>
<tbody>
{% for refund in declined.object_list %}
<tr class="refund">
{{ base_td(refund, amo) }}
<td class="long declined-date">
{{ refund.declined|babel_datetime }}
</td>
</tr>
{% if refund.rejection_reason %}
<tr class="reason">
<td colspan="0">
<b>{{ _('Rejection Reason:') }}</b>
{{ refund.rejection_reason }}
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
{{ declined|paginator }}
{% endif %}
</section>
{% include 'devhub/includes/addons_edit_nav.html' %}
{% endblock %}

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

@ -43,7 +43,7 @@ from devhub import tasks
import files
from files.models import File, FileUpload, Platform
from files.tests.test_models import UploadTest as BaseUploadTest
from market.models import AddonPremium, Price
from market.models import AddonPremium, Price, Refund
from reviews.models import Review
from stats.models import Contribution
from translations.models import Translation
@ -1333,7 +1333,7 @@ class TestMarketplace(MarketplaceMixin, amo.tests.TestCase):
class TestIssueRefund(amo.tests.TestCase):
fixtures = ('base/apps', 'base/users', 'base/addon_3615')
fixtures = ('base/users', 'base/addon_3615')
def setUp(self):
waffle.models.Switch.objects.create(name='allow-refund', active=True)
@ -1367,49 +1367,146 @@ class TestIssueRefund(amo.tests.TestCase):
r = self.client.get(self.url)
eq_(r.status_code, 404)
def _test_issue(self, refund, destination):
def _test_issue(self, refund, enqueue_refund):
c = self.make_purchase()
r = self.client.post(self.url, {'transaction_id': c.transaction_id,
'issue': '1'})
self.assertRedirects(r, reverse(destination), 302)
self.assertRedirects(r, self.addon.get_dev_url('refunds'), 302)
refund.assert_called_with(self.paykey)
eq_(len(mail.outbox), 1)
assert 'approved' in mail.outbox[0].subject
# There should be one approved refund added.
eq_(enqueue_refund.call_args_list[0][0], (amo.REFUND_APPROVED,))
@mock.patch('stats.models.Contribution.enqueue_refund')
@mock.patch('paypal.refund')
def test_addons_issue(self, refund):
self._test_issue(refund, 'devhub.addons')
def test_addons_issue(self, refund, enqueue_refund):
self._test_issue(refund, enqueue_refund)
@mock.patch('stats.models.Contribution.enqueue_refund')
@mock.patch('paypal.refund')
def test_apps_issue(self, refund):
self.addon.update(type=amo.ADDON_WEBAPP)
self._test_issue(refund, 'devhub.apps')
def test_apps_issue(self, refund, enqueue_refund):
self.addon.update(type=amo.ADDON_WEBAPP, app_slug='ballin')
self._test_issue(refund, enqueue_refund)
def _test_decline(self, refund, destination):
def _test_decline(self, refund, enqueue_refund):
c = self.make_purchase()
r = self.client.post(self.url, {'transaction_id': c.transaction_id,
'decline': ''})
self.assertRedirects(r, reverse(destination), 302)
self.assertRedirects(r, self.addon.get_dev_url('refunds'), 302)
assert not refund.called
eq_(len(mail.outbox), 1)
assert 'declined' in mail.outbox[0].subject
# There should be one declined refund added.
eq_(enqueue_refund.call_args_list[0][0], (amo.REFUND_DECLINED,))
@mock.patch('stats.models.Contribution.enqueue_refund')
@mock.patch('paypal.refund')
def test_addons_decline(self, refund):
self._test_decline(refund, 'devhub.addons')
def test_addons_decline(self, refund, enqueue_refund):
self._test_decline(refund, enqueue_refund)
@mock.patch('stats.models.Contribution.enqueue_refund')
@mock.patch('paypal.refund')
def test_apps_decline(self, refund):
self.addon.update(type=amo.ADDON_WEBAPP)
self._test_decline(refund, 'devhub.apps')
def test_apps_decline(self, refund, enqueue_refund):
self.addon.update(type=amo.ADDON_WEBAPP, app_slug='ballin')
self._test_decline(refund, enqueue_refund)
@mock.patch('stats.models.Contribution.enqueue_refund')
@mock.patch('paypal.refund')
def test_non_refundable_txn(self, refund):
def test_non_refundable_txn(self, refund, enqueue_refund):
c = self.make_purchase('56789', amo.CONTRIB_VOLUNTARY)
r = self.client.post(self.url, {'transaction_id': c.transaction_id,
'issue': ''})
eq_(r.status_code, 404)
assert not refund.called
assert not refund.called, '`paypal.refund` should not have been called'
assert not enqueue_refund.called, (
'`Contribution.enqueue_refund` should not have been called')
class TestRefunds(amo.tests.TestCase):
fixtures = ['base/addon_3615', 'base/users']
def setUp(self):
waffle.models.Switch.objects.create(name='allow-refund', active=True)
self.addon = Addon.objects.get(id=3615)
self.addon.premium_type = amo.ADDON_PREMIUM
self.addon.save()
self.user = UserProfile.objects.get(email='del@icio.us')
self.url = self.addon.get_dev_url('refunds')
self.client.login(username='del@icio.us', password='password')
self.queues = {
'pending': amo.REFUND_PENDING,
'approved': amo.REFUND_APPROVED,
'instant': amo.REFUND_APPROVED_INSTANT,
'declined': amo.REFUND_DECLINED,
}
def generate_refunds(self):
self.expected = {}
for status in amo.REFUND_STATUSES.keys():
for x in xrange(status + 1):
c = Contribution.objects.create(addon=self.addon,
user=self.user, type=amo.CONTRIB_PURCHASE)
r = Refund.objects.create(contribution=c, status=status)
self.expected.setdefault(status, []).append(r)
def test_anonymous(self):
self.client.logout()
r = self.client.get(self.url, follow=True)
self.assertRedirects(r,
'%s?to=%s' % (reverse('users.login'), self.url))
def test_bad_owner(self):
self.client.logout()
self.client.login(username='regular@mozilla.com', password='password')
r = self.client.get(self.url)
eq_(r.status_code, 403)
def test_owner(self):
r = self.client.get(self.url)
eq_(r.status_code, 200)
def test_admin(self):
self.client.logout()
self.client.login(username='admin@mozilla.com', password='password')
r = self.client.get(self.url)
eq_(r.status_code, 200)
def test_not_premium(self):
self.addon.premium_type = amo.ADDON_FREE
self.addon.save()
r = self.client.get(self.url)
eq_(r.status_code, 200)
eq_(pq(r.content)('#enable-payments').length, 1)
def test_empty_queues(self):
r = self.client.get(self.url)
eq_(r.status_code, 200)
for key, status in self.queues.iteritems():
eq_(list(r.context[key]), [])
def test_queues(self):
self.generate_refunds()
r = self.client.get(self.url)
eq_(r.status_code, 200)
for key, status in self.queues.iteritems():
eq_(list(r.context[key]), list(self.expected[status]))
def test_empty_tables(self):
r = self.client.get(self.url)
eq_(r.status_code, 200)
doc = pq(r.content)
for key in self.queues.keys():
eq_(doc('.no-results#queue-%s' % key).length, 1)
def test_tables(self):
self.generate_refunds()
r = self.client.get(self.url)
eq_(r.status_code, 200)
doc = pq(r.content)
eq_(doc('#enable-payments').length, 0)
for key in self.queues.keys():
eq_(doc('#queue-%s' % key).length, 1)
class TestDelete(amo.tests.TestCase):

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

@ -62,6 +62,7 @@ app_detail_patterns = patterns('',
url('^profile/remove$', views.remove_profile,
name='devhub.apps.profile.remove'),
url('^issue_refund$', views.issue_refund, name='devhub.apps.issue_refund'),
url('^refunds$', views.refunds, name='devhub.apps.refunds'),
)
# These will all start with /addon/<addon_id>/
@ -84,6 +85,7 @@ detail_patterns = patterns('',
url('^payments/', include(marketplace_patterns('addons'))),
url('^issue_refund$', views.issue_refund,
name='devhub.addons.issue_refund'),
url('^refunds$', views.refunds, name='devhub.addons.refunds'),
url('^profile$', views.profile, name='devhub.addons.profile'),
url('^profile/remove$', views.remove_profile,
name='devhub.addons.profile.remove'),

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

@ -46,7 +46,7 @@ from devhub import perf
from editors.helpers import get_position
from files.models import File, FileUpload, Platform
from files.utils import parse_addon
from market.models import AddonPremium
from market.models import AddonPremium, Refund
from paypal.check import Check
import paypal
from product_details import product_details
@ -79,7 +79,7 @@ class AddonFilter(BaseFilter):
class AppFilter(BaseFilter):
opts = (('name', _lazy(u'Name')),
('created', _lazy(u'Created')),
('downloads', loc(u'Weekly Downloads')),
('downloads', _lazy(u'Weekly Downloads')),
('rating', _lazy(u'Rating')))
@ -505,15 +505,18 @@ def issue_refund(request, addon_id, addon, webapp=False):
if 'issue' in request.POST:
paypal.refund(contribution.paykey)
contribution.mail_approved()
paypal_log.info('Refund issued for contribution %r' %
contribution.pk)
messages.success(request, 'Refund issued.')
refund = contribution.enqueue_refund(amo.REFUND_APPROVED)
paypal_log.info('Refund %r issued for contribution %r' %
(refund.pk, contribution.pk))
messages.success(request, _('Refund issued.'))
else:
contribution.mail_declined()
paypal_log.info('Refund declined for contribution %r' %
contribution.pk)
messages.success(request, 'Refund declined.')
return redirect('devhub.%s' % ('apps' if webapp else 'addons'))
# TODO: Consider requiring a rejection reason for declined refunds.
refund = contribution.enqueue_refund(amo.REFUND_DECLINED)
paypal_log.info('Refund %r declined for contribution %r' %
(refund.pk, contribution.pk))
messages.success(request, _('Refund declined.'))
return redirect(addon.get_dev_url('refunds'))
else:
return jingo.render(request, 'devhub/payments/issue-refund.html',
{'contribution': contribution,
@ -522,6 +525,23 @@ def issue_refund(request, addon_id, addon, webapp=False):
'transaction_id': txn_id})
@waffle_switch('allow-refund')
@dev_required(webapp=True)
# TODO: Make sure 'Support' staff can access this.
def refunds(request, addon_id, addon, webapp=False):
ctx = {'addon': addon, 'webapp': webapp}
queues = {
'pending': Refund.objects.pending(addon),
'approved': Refund.objects.approved(addon),
'instant': Refund.objects.instant(addon),
'declined': Refund.objects.declined(addon),
}
# For now set the limit to something stupid so this is stupid easy to QA.
for status, refunds in queues.iteritems():
ctx[status] = amo.utils.paginate(request, refunds, per_page=5)
return jingo.render(request, 'devhub/payments/refunds.html', ctx)
@dev_required
@post_required
def disable_payments(request, addon_id, addon):

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

@ -216,3 +216,50 @@ class PreApprovalUser(amo.models.ModelBase):
class Meta:
db_table = 'users_preapproval'
class RefundManager(amo.models.ManagerBase):
def by_addon(self, addon):
return self.filter(contribution__addon=addon)
def pending(self, addon=None):
return self.by_addon(addon).filter(status=amo.REFUND_PENDING)
def approved(self, addon):
return self.by_addon(addon).filter(status=amo.REFUND_APPROVED)
def instant(self, addon):
return self.by_addon(addon).filter(status=amo.REFUND_APPROVED_INSTANT)
def declined(self, addon):
return self.by_addon(addon).filter(status=amo.REFUND_DECLINED)
class Refund(amo.models.ModelBase):
# This refers to the original object with `type=amo.CONTRIB_PURCHASE`.
contribution = models.OneToOneField('stats.Contribution')
# Pending => 0
# Approved => 1
# Instantly Approved => 2
# Declined => 3
status = models.PositiveIntegerField(default=amo.REFUND_PENDING,
choices=do_dictsort(amo.REFUND_STATUSES), db_index=True)
refund_reason = models.TextField(default='', blank=True)
rejection_reason = models.TextField(default='', blank=True)
# Date `created` should always be date `requested` for pending refunds,
# but let's just stay on the safe side. We might change our minds.
requested = models.DateTimeField(null=True, db_index=True)
approved = models.DateTimeField(null=True, db_index=True)
declined = models.DateTimeField(null=True, db_index=True)
objects = RefundManager()
class Meta:
db_table = 'refunds'
def __unicode__(self):
return u'%s (%s)' % (self.contribution, self.get_status_display())

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

@ -1,18 +1,19 @@
import datetime
from decimal import Decimal
from django.utils import translation
import mock
from nose.tools import eq_
from addons.models import Addon
import amo
import amo.tests
from market.models import AddonPremium, PreApprovalUser, Price, PriceCurrency
from addons.models import Addon
from market.models import (AddonPremium, PreApprovalUser, Price, PriceCurrency,
Refund)
from stats.models import Contribution
from users.models import UserProfile
from django.utils import translation
class TestPremium(amo.tests.TestCase):
fixtures = ['prices.json', 'base/addon_3615.json']
@ -116,16 +117,15 @@ class TestPrice(amo.tests.TestCase):
eq_(currencies[1][1].currency, 'CAD')
class TestContribution(amo.tests.TestCase):
fixtures = ['base/addon_3615', 'base/users']
class ContributionMixin(object):
def setUp(self):
self.addon = Addon.objects.get(pk=3615)
self.user = UserProfile.objects.get(pk=999)
def create(self, type):
Contribution.objects.create(type=type, addon=self.addon,
user=self.user)
return Contribution.objects.create(type=type, addon=self.addon,
user=self.user)
def purchased(self):
return (self.addon.addonpurchase_set
@ -135,6 +135,10 @@ class TestContribution(amo.tests.TestCase):
def type(self):
return self.addon.addonpurchase_set.get(user=self.user).type
class TestContribution(ContributionMixin, amo.tests.TestCase):
fixtures = ['base/addon_3615', 'base/users']
def test_purchase(self):
self.create(amo.CONTRIB_PURCHASE)
assert self.purchased()
@ -199,6 +203,148 @@ class TestContribution(amo.tests.TestCase):
eq_(list(self.user.purchase_ids()), [])
class TestRefundContribution(ContributionMixin, amo.tests.TestCase):
fixtures = ['base/addon_3615', 'base/users']
def setUp(self):
super(TestRefundContribution, self).setUp()
self.contribution = self.create(amo.CONTRIB_PURCHASE)
def do_refund(self, expected, status, refund_reason=None,
rejection_reason=None):
"""Checks that a refund is enqueued and contains the correct values."""
self.contribution.enqueue_refund(status, refund_reason,
rejection_reason)
expected.update(contribution=self.contribution, status=status)
eq_(Refund.objects.count(), 1)
refund = Refund.objects.filter(**expected)
eq_(refund.exists(), True)
return refund[0]
def test_pending(self):
reason = 'this is bloody bullocks, mate'
expected = dict(refund_reason=reason,
requested__isnull=False,
approved=None,
declined=None)
refund = self.do_refund(expected, amo.REFUND_PENDING, reason)
assert amo.tests.close_to_now(refund.requested), (
'Expected date `requested` to be now. Got %r.' % refund.requested)
def test_pending_to_approved(self):
reason = 'this is bloody bullocks, mate'
expected = dict(refund_reason=reason,
requested__isnull=False,
approved=None,
declined=None)
refund = self.do_refund(expected, amo.REFUND_PENDING, reason)
assert amo.tests.close_to_now(refund.requested), (
'Expected date `requested` to be now. Got %r.' % refund.requested)
# Change `requested` date to some date in the past.
requested_date = refund.requested - datetime.timedelta(hours=1)
refund.requested = requested_date
refund.save()
expected = dict(refund_reason=reason,
requested__isnull=False,
approved__isnull=False,
declined=None)
refund = self.do_refund(expected, amo.REFUND_APPROVED)
eq_(refund.requested, requested_date,
'Expected date `requested` to remain unchanged.')
assert amo.tests.close_to_now(refund.approved), (
'Expected date `approved` to be now. Got %r.' % refund.approved)
def test_approved_instant(self):
expected = dict(refund_reason='',
requested__isnull=False,
approved__isnull=False,
declined=None)
refund = self.do_refund(expected, amo.REFUND_APPROVED_INSTANT)
assert amo.tests.close_to_now(refund.requested), (
'Expected date `requested` to be now. Got %r.' % refund.requested)
assert amo.tests.close_to_now(refund.approved), (
'Expected date `approved` to be now. Got %r.' % refund.approved)
def test_pending_to_declined(self):
refund_reason = 'please, bro'
rejection_reason = 'sorry, brah'
expected = dict(refund_reason=refund_reason,
rejection_reason='',
requested__isnull=False,
approved=None,
declined=None)
refund = self.do_refund(expected, amo.REFUND_PENDING, refund_reason)
assert amo.tests.close_to_now(refund.requested), (
'Expected date `requested` to be now. Got %r.' % refund.requested)
requested_date = refund.requested - datetime.timedelta(hours=1)
refund.requested = requested_date
refund.save()
expected = dict(refund_reason=refund_reason,
rejection_reason=rejection_reason,
requested__isnull=False,
approved=None,
declined__isnull=False)
refund = self.do_refund(expected, amo.REFUND_DECLINED,
rejection_reason=rejection_reason)
eq_(refund.requested, requested_date,
'Expected date `requested` to remain unchanged.')
assert amo.tests.close_to_now(refund.declined), (
'Expected date `declined` to be now. Got %r.' % refund.declined)
class TestRefundManager(amo.tests.TestCase):
fixtures = ['base/addon_3615', 'base/users']
def setUp(self):
self.addon = Addon.objects.get(id=3615)
self.user = UserProfile.objects.get(email='del@icio.us')
self.expected = {}
for status in amo.REFUND_STATUSES.keys():
c = Contribution.objects.create(addon=self.addon, user=self.user,
type=amo.CONTRIB_PURCHASE)
self.expected[status] = Refund.objects.create(contribution=c,
status=status)
def test_all(self):
eq_(sorted(Refund.objects.values_list('id', flat=True)),
sorted(e.id for e in self.expected.values()))
def test_pending(self):
eq_(list(Refund.objects.pending(self.addon)),
[self.expected[amo.REFUND_PENDING]])
def test_approved(self):
eq_(list(Refund.objects.approved(self.addon)),
[self.expected[amo.REFUND_APPROVED]])
def test_instant(self):
eq_(list(Refund.objects.instant(self.addon)),
[self.expected[amo.REFUND_APPROVED_INSTANT]])
def test_declined(self):
eq_(list(Refund.objects.declined(self.addon)),
[self.expected[amo.REFUND_DECLINED]])
def test_by_addon(self):
other = Addon.objects.create(type=amo.ADDON_WEBAPP)
c = Contribution.objects.create(addon=other, user=self.user,
type=amo.CONTRIB_PURCHASE)
ref = Refund.objects.create(contribution=c, status=amo.REFUND_DECLINED)
declined = Refund.objects.filter(status=amo.REFUND_DECLINED)
eq_(sorted(r.id for r in declined),
sorted(r.id for r in [self.expected[amo.REFUND_DECLINED], ref]))
eq_(sorted(r.id for r in Refund.objects.by_addon(addon=self.addon)),
sorted(r.id for r in self.expected.values()))
eq_(list(Refund.objects.by_addon(addon=other)), [ref])
class TestUserPreApproval(amo.tests.TestCase):
fixtures = ['base/users']

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

@ -13,6 +13,7 @@ import tower
from tower import ugettext as _
import amo
from amo.helpers import absolutify, urlparams
from amo.models import ModelBase, SearchMixin
from amo.fields import DecimalCharField
from amo.utils import send_mail
@ -283,6 +284,31 @@ class Contribution(amo.models.ModelBase):
del(self.post_data['payer_email'])
self.save()
def enqueue_refund(self, status, refund_reason=None,
rejection_reason=None):
"""Keep track of a contribution's refund status."""
from market.models import Refund
refund, c = Refund.objects.safer_get_or_create(contribution=self)
refund.status = status
# Determine which timestamps to update.
timestamps = []
if status in (amo.REFUND_PENDING, amo.REFUND_APPROVED_INSTANT):
timestamps.append('requested')
if status in (amo.REFUND_APPROVED, amo.REFUND_APPROVED_INSTANT):
timestamps.append('approved')
elif status == amo.REFUND_DECLINED:
timestamps.append('declined')
for ts in timestamps:
setattr(refund, ts, datetime.datetime.now())
if refund_reason:
refund.refund_reason = refund_reason
if rejection_reason:
refund.rejection_reason = rejection_reason
refund.save()
return refund
@staticmethod
def post_save(sender, instance, **kwargs):
from . import tasks
@ -297,6 +323,13 @@ class Contribution(amo.models.ModelBase):
self.currency or 'USD',
locale=locale)
def get_refund_url(self):
return urlparams(self.addon.get_dev_url('issue_refund'),
transaction_id=self.transaction_id)
def get_absolute_refund_url(self):
return absolutify(self.get_refund_url())
def is_instant_refund(self):
limit = datetime.timedelta(seconds=settings.PAYPAL_REFUND_INSTANT)
return datetime.datetime.now() < (self.created + limit)

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

@ -4,6 +4,6 @@ Purchased on {{ contribution.date|datetime }}
Price: {{ contribution.get_amount_locale() }}
Reason given:
{{ form.cleaned_data.text }}
{{ reason }}
To approve or deny this request, click here: {{ refund_url }}

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

@ -1622,14 +1622,18 @@ class TestPurchases(amo.tests.TestCase):
res = self.client.post(self.get_url('reason'), {})
eq_(res.status_code, 200)
@patch('stats.models.Contribution.enqueue_refund')
@patch('stats.models.Contribution.is_instant_refund')
@patch('paypal.refund')
def test_request_instant(self, is_instant_refund, refund):
def test_request_instant(self, refund, is_instant_refund, enqueue_refund):
is_instant_refund.return_value = True
self.client.post(self.get_url('request'), {'remove': 1})
res = self.client.post(self.get_url('reason'), {})
assert refund.called
eq_(res.status_code, 302)
# There should be one instant refund added.
eq_(enqueue_refund.call_args_list[0][0],
(amo.REFUND_APPROVED_INSTANT,))
def test_free_shows_up(self):
Contribution.objects.all().delete()

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

@ -34,7 +34,7 @@ from amo.decorators import (json_view, login_required, no_login_required,
permission_required, write, post_required)
from amo.forms import AbuseForm
from amo.urlresolvers import reverse
from amo.helpers import absolutify, loc
from amo.helpers import loc
from amo.utils import escape_all, log_cef, send_mail, urlparams
from abuse.models import send_abuse_report
from addons.models import Addon
@ -897,7 +897,10 @@ def refund_reason(request, contribution, wizard):
if contribution.is_instant_refund():
paypal.refund(contribution.paykey)
paypal_log.info('Refund issued for contribution %r' % contribution.pk)
# TODO: Consider requiring a refund reason for instant refunds.
refund = contribution.enqueue_refund(amo.REFUND_APPROVED_INSTANT)
paypal_log.info('Refund %r issued for contribution %r' %
(refund.pk, contribution.pk))
# Note: we have to wait for PayPal to issue an IPN before it's
# completely refunded.
messages.success(request, _('Refund is being processed.'))
@ -905,21 +908,23 @@ def refund_reason(request, contribution, wizard):
form = forms.ContactForm(request.POST or None)
if request.method == 'POST' and form.is_valid():
url = absolutify(urlparams(addon.get_dev_url('issue_refund'),
transaction_id=contribution.transaction_id))
reason = form.cleaned_data['text']
template = jingo.render_to_string(request,
wizard.tpl('emails/refund-request.txt'),
context={'addon': addon,
'form': form,
'user': request.amo_user,
'contribution': contribution,
'refund_url': url})
'refund_url': contribution.get_absolute_refund_url(),
'refund_reason': reason})
log.info('Refund request sent by user: %s for addon: %s' %
(request.amo_user.pk, addon.pk))
# L10n: %s is the addon name.
send_mail(_(u'New Refund Request for %s' % addon.name),
template, settings.NOBODY_EMAIL,
[smart_str(addon.support_email)])
# Add this refund request to the queue.
contribution.enqueue_refund(amo.REFUND_PENDING, reason)
return redirect(reverse('users.support',
args=[contribution.pk, 'refund-sent']))

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

@ -525,6 +525,8 @@ MINIFY_BUNDLES = {
'css/impala/formset.less',
'css/devhub/forms.less',
'css/devhub/submission.less',
'css/devhub/refunds.less',
'css/devhub/buttons.less',
),
'zamboni/devhub_impala': (
'css/impala/developers.less',
@ -535,6 +537,7 @@ MINIFY_BUNDLES = {
'css/devhub/forms.less',
'css/devhub/submission.less',
'css/devhub/search.less',
'css/devhub/refunds.less',
),
'zamboni/editors': (
'css/zamboni/editors.css',

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

@ -0,0 +1,18 @@
@import '../impala/lib';
button, .button {
&.good {
.gradient-two-color(@button-green-light, @button-green-dark,
@button-green-dark);
&:active {
.gradient-two-color(@button-green-light, @button-green-dark);
}
}
&.bad {
.gradient-two-color(@button-red-light, @button-red-dark,
@button-red-dark);
&:active {
.gradient-two-color(@button-red-dark, @button-red-light);
}
}
}

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

@ -1,5 +1,72 @@
@import '../impala/lib';
.devhub-form {
position: relative;
table {
margin: 0;
width: 100%;
th, td {
border-top: 1px dotted #add0dc;
padding: 8px 0;
}
tr:first-child {
td, th {
border-top: medium none;
}
}
th {
padding-right: 20px;
vertical-align: top;
width: 130px;
}
ul {
margin: 0;
}
table td {
border-top-width: 0;
}
.screenshot.thumbnail {
width: 100px;
height: 75px;
}
}
table, tbody {
border-top: medium none;
border-bottom: medium none;
}
}
.html-rtl .devhub-form table th {
padding: 8px 0 8px 20px;
}
.devhub-form .tip,
.addon-submission-process .tip {
margin-left: 3px;
}
.devhub-form .item {
.border-radius(8px);
background: #fff;
border: 1px solid @border-blue;
clear: both;
margin: 0 0 1em;
position: relative;
+ h3 {
margin-top: 1.5em;
}
}
.devhub-form .listing-footer {
.border-radius(0 0 8px 8px);
border-top: 1px solid @border-blue;
padding: 9px;
}
.devhub-form .item_wrapper {
padding: 5px 13px;
}
.devhub-form .tip,
.addon-submission-process .tip,
ul.errorlist li span.tip,

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

@ -0,0 +1,98 @@
@import '../impala/lib';
#refunds {
h3 {
clear: both; /* To clear the old-style paginator. Let's kill that later. */
em {
color: @medium-gray;
}
}
.item {
padding: 0;
}
.price {
color: @green;
}
.long {
white-space: nowrap;
}
th, td {
padding: 5px;
}
th {
background: @border-blue;
border-bottom: 1px dotted #ADD0DC;
color: @medium-gray;
font-size: 11px;
}
td {
font-size: 11px;
vertical-align: top;
}
.reason td {
background: lighten(@link, 45%);
border: 0;
color: @medium-gray;
font-size: 11px;
padding-top: 0;
}
/* Tables suck and can't deal with `border-radius`. So deal with this. */
thead tr:first-child th {
&:first-child {
-moz-border-radius-topleft: 5px;
-webkit-border-radius-topleft: 5px;
border-top-left-radius: 5px;
}
&:last-child {
-moz-border-radius-topright: 5px;
-webkit-border-radius-topright: 5px;
border-top-right-radius: 5px;
}
}
tbody tr:last-child td {
.border-radius(0 0 5px 5px);
}
/* Styles for "Action" buttons. */
#queue-pending tbody {
tr:last-child td {
-moz-border-radius-bottomright: 0;
-webkit-border-radius-bottomright: 0;
border-bottom-right-radius: 0;
}
.action {
border: 1px solid @border-blue;
border-width: 0 0 0 1px;
}
}
form {
margin: 0;
button {
font-size: 12px;
line-height: inherit;
padding: 0 30px;
width: 100%;
+ button {
margin-top: 5px;
}
}
}
}
.html-rtl #refunds {
thead tr:first-child th {
&:first-child {
.border-radius(0 5px 0 0);
}
&:last-child {
.border-radius(5px 0 0 0);
}
}
#queue-pending tbody {
tr:last-child td {
.border-radius(0 0 5px 0);
}
.action {
border-width: 0 1px 0 0;
}
}
}

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

@ -17,13 +17,12 @@
}
button.good, .button.good, .button.add { // Green
background: #489615;
.gradient-two-color(#84C63C, #489615);
.gradient-two-color(@button-green-light, @button-green-dark,
@button-green-dark);
}
button.bad, .button.bad, .button.developer, .button.scary { // Red
background: #bc2b1a;
.gradient-two-color(#f84b4e, #bc2b1a);
.gradient-two-color(@button-red-light, @button-red-dark, @button-red-dark);
}
.button {

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

@ -16,6 +16,13 @@
@green: #093;
// Button Colors
@button-green-light: #84c63c;
@button-green-dark: #489615;
@button-red-light: #f84b4e;
@button-red-dark: #bc2b1a;
// Font Stacks
@sans-stack: "Helvetica Neue", Arial, sans-serif;
@head-sans: "Trebuchet MS", sans-serif;

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

@ -12,15 +12,6 @@ form .char-count:after,
visibility: hidden;
}
/* @group Edit addon */
.devhub-form {
position: relative;
}
.devhub-form .tip,
.addon-submission-process .tip {
margin-left: 3px;
}
#edit-addon .edit-addon-section {
margin-bottom:3em;
width: 100%;
@ -66,74 +57,7 @@ form .char-count:after,
padding-top: 0;
}
.devhub-form .item {
background-color: #FFFFFF;
background-position: left bottom;
background-repeat: repeat-x;
clear: both;
position: relative;
border: 1px solid #C9E8F3;
margin: 0 0 1em;
-moz-border-radius: 8px;
-webkit-border-radius: 8px;
border-radius: 8px;
}
.devhub-form .listing-footer {
-moz-border-radius: 0 0 8px 8px;
-webkit-border-radius: 0 0 8px 8px;
border-radius: 0 0 8px 8px;
border-top: 1px solid #C9E8F3;
padding: 9px;
}
.devhub-form .item_wrapper {
padding: 5px 13px;
}
.devhub-form table {
margin: 0;
width: 100%;
}
.devhub-form table th {
width: 130px;
vertical-align: top;
padding: 8px 20px 8px 0;
border-top: 1px dotted #ADD0DC;
}
.html-rtl .devhub-form table th {
padding: 8px 0 8px 20px;
}
.devhub-form table td {
padding: 8px 0;
border-top: 1px dotted #ADD0DC;
}
.devhub-form table ul {
margin: 0;
}
.devhub-form table table td {
border-top-width: 0;
}
.devhub-form table, .devhub-form tbody {
border-top: medium none;
border-bottom: medium none;
}
.devhub-form table tr:first-child td,
.devhub-form table tr:first-child th {
border-top: medium none;
}
.devhub-form table .screenshot.thumbnail {
width: 100px;
height: 75px;
}
ul.refinements:last-child {
border-bottom-width: 0;

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

@ -0,0 +1,21 @@
CREATE TABLE `refunds` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`created` datetime NOT NULL,
`modified` datetime NOT NULL,
`contribution_id` int(11) unsigned NOT NULL,
`status` int(11) unsigned NOT NULL,
`refund_reason` longtext NOT NULL,
`rejection_reason` longtext NOT NULL,
`requested` datetime DEFAULT NULL,
`approved` datetime DEFAULT NULL,
`declined` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `refunds_contribution_id_idx` (`contribution_id`),
KEY `refunds_status_idx` (`status`),
KEY `refunds_requested_idx` (`requested`),
KEY `refunds_approved_idx` (`approved`),
KEY `refunds_declined_idx` (`declined`),
CONSTRAINT `contribution_id_pk`
FOREIGN KEY (`contribution_id`)
REFERENCES `stats_contributions` (`id`)
) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_general_ci;