record and list all refunds (bug 702981)
This commit is contained in:
Родитель
81e06016c9
Коммит
f6954ae4e4
|
@ -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;
|
Загрузка…
Ссылка в новой задаче