Fix #571 -- Partial payments and refunds

This commit is contained in:
Raphael Michel
2018-06-26 12:09:36 +02:00
parent 8e7af49206
commit 18a378976b
115 changed files with 6026 additions and 1598 deletions

View File

@@ -23,7 +23,7 @@ def env():
code='FOOBAR', event=event, email='dummy@dummy.test',
status=Order.STATUS_PENDING,
datetime=now(), expires=now() + timedelta(days=10),
total=Decimal('13.37'), payment_provider='banktransfer'
total=Decimal('13.37'),
)
shirt = Item.objects.create(event=event, name='T-Shirt', default_price=12)
shirt_red = ItemVariation.objects.create(item=shirt, default_price=14, value="Red")

View File

@@ -25,13 +25,13 @@ def env():
code='1Z3AS', event=event,
status=Order.STATUS_PENDING,
datetime=now(), expires=now() + timedelta(days=10),
total=23, payment_provider='banktransfer'
total=23,
)
o2 = Order.objects.create(
code='6789Z', event=event,
status=Order.STATUS_CANCELED,
datetime=now(), expires=now() + timedelta(days=10),
total=23, payment_provider='banktransfer'
total=23,
)
quota = Quota.objects.create(name="Test", size=2, event=event)
item1 = Item.objects.create(event=event, name="Ticket", default_price=23)
@@ -56,23 +56,6 @@ def test_discard(env, client):
assert trans.payer == ''
@pytest.mark.django_db
def test_accept_wrong_amount(env, client):
job = BankImportJob.objects.create(event=env[0])
trans = BankTransaction.objects.create(event=env[0], import_job=job, payer='Foo',
state=BankTransaction.STATE_INVALID,
amount=12, date='unknown', order=env[2])
client.login(email='dummy@dummy.dummy', password='dummy')
r = json.loads(client.post('/control/event/{}/{}/banktransfer/action/'.format(env[0].organizer.slug, env[0].slug), {
'action_{}'.format(trans.pk): 'accept',
}).content.decode('utf-8'))
assert r['status'] == 'ok'
trans.refresh_from_db()
assert trans.state == BankTransaction.STATE_VALID
env[2].refresh_from_db()
assert env[2].status == Order.STATUS_PAID
@pytest.mark.django_db
def test_assign_order(env, client):
job = BankImportJob.objects.create(event=env[0])

View File

@@ -28,13 +28,13 @@ def env():
code='1Z3AS', event=event,
status=Order.STATUS_PENDING,
datetime=now(), expires=now() + timedelta(days=10),
total=23, payment_provider='banktransfer'
total=23
)
o2 = Order.objects.create(
code='6789Z', event=event,
status=Order.STATUS_CANCELED,
datetime=now(), expires=now() + timedelta(days=10),
total=23, payment_provider='banktransfer'
total=23
)
quota = Quota.objects.create(name="Test", size=2, event=event)
item1 = Item.objects.create(event=event, name="Ticket", default_price=23)

View File

@@ -7,7 +7,8 @@ from django.core.files.uploadedfile import SimpleUploadedFile
from django.utils.timezone import now
from pretix.base.models import (
Event, Item, Order, OrderPosition, Organizer, Quota, Team, User,
Event, Item, Order, OrderPayment, OrderPosition, Organizer, Quota, Team,
User,
)
from pretix.plugins.banktransfer.models import BankImportJob
from pretix.plugins.banktransfer.tasks import process_banktransfers
@@ -28,19 +29,19 @@ def env():
code='1Z3AS', event=event,
status=Order.STATUS_PENDING,
datetime=now(), expires=now() + timedelta(days=10),
total=23, payment_provider='banktransfer'
total=23
)
o2 = Order.objects.create(
code='6789Z', event=event,
status=Order.STATUS_CANCELED,
datetime=now(), expires=now() + timedelta(days=10),
total=23, payment_provider='banktransfer'
total=23
)
Order.objects.create(
code='GS89Z', event=event,
status=Order.STATUS_CANCELED,
datetime=now(), expires=now() + timedelta(days=10),
total=23, payment_provider='banktransfer'
total=23
)
quota = Quota.objects.create(name="Test", size=2, event=event)
item1 = Item.objects.create(event=event, name="Ticket", default_price=23)
@@ -110,7 +111,43 @@ def test_mark_paid(env, job):
@pytest.mark.django_db
def test_check_amount(env, job):
def test_underpaid(env, job):
process_banktransfers(job, [{
'payer': 'Karla Kundin',
'reference': 'Bestellung DUMMY1Z3AS',
'date': '2016-01-26',
'amount': '22.50'
}])
env[2].refresh_from_db()
assert env[2].status == Order.STATUS_PENDING
p = env[2].payments.last()
assert p.amount == Decimal('22.50')
assert p.state == OrderPayment.PAYMENT_STATE_CONFIRMED
assert env[2].pending_sum == Decimal('0.50')
@pytest.mark.django_db
def test_in_parts(env, job):
process_banktransfers(job, [{
'payer': 'Karla Kundin',
'reference': 'Bestellung DUMMY1Z3AS',
'date': '2016-01-26',
'amount': '10.00'
}])
process_banktransfers(job, [{
'payer': 'Karla Kundin',
'reference': 'Bestellung DUMMY1Z3AS',
'date': '2016-01-26',
'amount': '13.00'
}])
env[2].refresh_from_db()
assert env[2].status == Order.STATUS_PAID
assert env[2].payments.count() == 2
assert env[2].pending_sum == Decimal('0.00')
@pytest.mark.django_db
def test_overpaid(env, job):
process_banktransfers(job, [{
'payer': 'Karla Kundin',
'reference': 'Bestellung DUMMY1Z3AS',
@@ -118,7 +155,11 @@ def test_check_amount(env, job):
'amount': '23.50'
}])
env[2].refresh_from_db()
assert env[2].status == Order.STATUS_PENDING
assert env[2].status == Order.STATUS_PAID
p = env[2].payments.last()
assert p.amount == Decimal('23.50')
assert p.state == OrderPayment.PAYMENT_STATE_CONFIRMED
assert env[2].pending_sum == Decimal('-0.50')
@pytest.mark.django_db

View File

@@ -6,7 +6,7 @@ import pytest
from django.utils.timezone import now
from pretix.base.models import (
Event, Order, Organizer, RequiredAction, Team, User,
Event, Order, OrderPayment, OrderRefund, Organizer, Team, User,
)
from pretix.plugins.paypal.models import ReferencedPayPalObject
@@ -26,8 +26,13 @@ def env():
code='FOOBAR', event=event, email='dummy@dummy.test',
status=Order.STATUS_PAID,
datetime=now(), expires=now() + timedelta(days=10),
total=Decimal('13.37'), payment_provider='paypal',
payment_info=json.dumps({
total=Decimal('13.37'),
)
o1.payments.create(
amount=o1.total,
provider='paypal',
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
info=json.dumps({
"id": "PAY-5YK922393D847794YKER7MUI",
"create_time": "2013-02-19T22:01:53Z",
"update_time": "2013-02-19T22:01:55Z",
@@ -150,6 +155,33 @@ def get_test_charge(order: Order):
}
def get_test_refund(order: Order):
return {
'refund_from_received_amount': {'value': '13.30', 'currency': 'EUR'},
'amount': {'total': '13.37', 'currency': 'EUR'},
'sale_id': '1G495778AR8401726',
'update_time': '2018-07-24T07:50:07Z',
'total_refunded_amount': {'value': '13.37', 'currency': 'EUR'},
'refund_reason_code': 'REFUND',
'invoice_number': 'Test',
'parent_payment': 'PAY-0UB50445HE155450FLNLNMUY',
'state': 'completed',
'create_time': '2018-07-24T07:50:07Z',
'refund_from_transaction_fee': {'value': '0.07', 'currency': 'EUR'},
'id': '93M41501U3542574L',
'refund_to_payer': {'value': '13.37', 'currency': 'EUR'},
'links': [
{'method': 'GET', 'rel': 'self',
'href': 'https://api.sandbox.paypal.com/v1/payments/refund/93M41501U3542574L'},
{'method': 'GET',
'rel': 'parent_payment',
'href': 'https://api.sandbox.paypal.com/v1/payments/payment/PAY-0UB50445HE155450FLNLNMUY'},
{'method': 'GET', 'rel': 'sale',
'href': 'https://api.sandbox.paypal.com/v1/payments/sale/1G495778AR8401726'}
]
}
@pytest.mark.django_db
def test_webhook_all_good(env, client, monkeypatch):
charge = get_test_charge(env[1])
@@ -191,6 +223,7 @@ def test_webhook_global(env, client, monkeypatch):
order = env[1]
order.status = Order.STATUS_PENDING
order.save()
order.payments.update(state=OrderPayment.PAYMENT_STATE_PENDING)
charge = get_test_charge(env[1])
monkeypatch.setattr("paypalrestsdk.Sale.find", lambda *args: charge)
@@ -231,6 +264,7 @@ def test_webhook_mark_paid(env, client, monkeypatch):
order = env[1]
order.status = Order.STATUS_PENDING
order.save()
order.payments.update(state=OrderPayment.PAYMENT_STATE_PENDING)
charge = get_test_charge(env[1])
monkeypatch.setattr("paypalrestsdk.Sale.find", lambda *args: charge)
@@ -269,8 +303,10 @@ def test_webhook_mark_paid(env, client, monkeypatch):
def test_webhook_refund1(env, client, monkeypatch):
charge = get_test_charge(env[1])
charge['state'] = 'refunded'
refund = get_test_refund(env[1])
monkeypatch.setattr("paypalrestsdk.Sale.find", lambda *args: charge)
monkeypatch.setattr("paypalrestsdk.Refund.find", lambda *args: refund)
monkeypatch.setattr("pretix.plugins.paypal.payment.Paypal.init_api", lambda *args: None)
client.post('/dummy/dummy/paypal/webhook/', json.dumps(
@@ -309,21 +345,22 @@ def test_webhook_refund1(env, client, monkeypatch):
order.refresh_from_db()
assert order.status == Order.STATUS_PAID
ra = RequiredAction.objects.get(action_type="pretix.plugins.paypal.refund")
client.login(username='dummy@dummy.dummy', password='dummy')
client.post('/control/event/dummy/dummy/paypal/refund/{}/'.format(ra.pk))
order = env[1]
order.refresh_from_db()
assert order.status == Order.STATUS_REFUNDED
r = order.refunds.first()
assert r.provider == 'paypal'
assert r.amount == order.total
assert r.payment == order.payments.first()
assert r.state == OrderRefund.REFUND_STATE_EXTERNAL
assert r.source == OrderRefund.REFUND_SOURCE_EXTERNAL
@pytest.mark.django_db
def test_webhook_refund2(env, client, monkeypatch):
charge = get_test_charge(env[1])
charge['state'] = 'refunded'
refund = get_test_refund(env[1])
monkeypatch.setattr("paypalrestsdk.Sale.find", lambda *args: charge)
monkeypatch.setattr("paypalrestsdk.Refund.find", lambda *args: refund)
monkeypatch.setattr("pretix.plugins.paypal.payment.Paypal.init_api", lambda *args: None)
client.post('/dummy/dummy/paypal/webhook/', json.dumps(
@@ -331,7 +368,7 @@ def test_webhook_refund2(env, client, monkeypatch):
# Sample obtained in the webhook simulator
"id": "WH-2N242548W9943490U-1JU23391CS4765624",
"create_time": "2014-10-31T15:42:24Z",
"resource_type": "sale",
"resource_type": "refund",
"event_type": "PAYMENT.SALE.REFUNDED",
"summary": "A 0.01 USD sale payment was refunded",
"resource": {
@@ -356,10 +393,9 @@ def test_webhook_refund2(env, client, monkeypatch):
order.refresh_from_db()
assert order.status == Order.STATUS_PAID
ra = RequiredAction.objects.get(action_type="pretix.plugins.paypal.refund")
client.login(username='dummy@dummy.dummy', password='dummy')
client.post('/control/event/dummy/dummy/paypal/refund/{}/'.format(ra.pk))
order = env[1]
order.refresh_from_db()
assert order.status == Order.STATUS_REFUNDED
r = order.refunds.first()
assert r.provider == 'paypal'
assert r.amount == order.total
assert r.payment == order.payments.first()
assert r.state == OrderRefund.REFUND_STATE_EXTERNAL
assert r.source == OrderRefund.REFUND_SOURCE_EXTERNAL

View File

@@ -32,7 +32,7 @@ def env():
o1 = Order.objects.create(
code='FOO', event=event, status=Order.STATUS_PAID,
datetime=now(), expires=now() + timedelta(days=10),
total=0, payment_provider='banktransfer'
total=0
)
op1 = OrderPosition.objects.create(
order=o1, item=shirt, variation=shirt_red,

View File

@@ -35,7 +35,7 @@ def env():
o1 = Order.objects.create(
code='FOO', event=event, status=Order.STATUS_PAID,
datetime=now(), expires=now() + timedelta(days=10),
total=0, payment_provider='banktransfer'
total=0
)
op1 = OrderPosition.objects.create(
order=o1, item=shirt, variation=shirt_red,

View File

@@ -5,9 +5,9 @@ from decimal import Decimal
import pytest
from django.test import RequestFactory
from django.utils.timezone import now
from stripe import APIConnectionError, CardError, StripeError
from stripe.error import APIConnectionError, CardError, StripeError
from pretix.base.models import Event, Order, Organizer
from pretix.base.models import Event, Order, OrderRefund, Organizer
from pretix.base.payment import PaymentException
from pretix.plugins.stripe.payment import StripeCC
@@ -23,7 +23,7 @@ def env():
code='FOOBAR', event=event, email='dummy@dummy.test',
status=Order.STATUS_PENDING,
datetime=now(), expires=now() + timedelta(days=10),
total=Decimal('13.37'), payment_provider='banktransfer'
total=Decimal('13.37')
)
return event, o1
@@ -39,11 +39,16 @@ def factory():
return RequestFactory()
class MockedRefunds():
pass
class MockedCharge():
def __init__(self):
self.status = ''
self.paid = False
self.id = 'ch_123345345'
self.refunds = MockedRefunds()
def refresh(self):
pass
@@ -72,7 +77,10 @@ def test_perform_success(env, factory, monkeypatch):
req.session = {}
prov.checkout_prepare(req, {})
assert 'payment_stripe_token' in req.session
prov.payment_perform(req, order)
payment = order.payments.create(
provider='stripe_cc', amount=order.total
)
prov.execute_payment(req, payment)
order.refresh_from_db()
assert order.status == Order.STATUS_PAID
@@ -102,7 +110,10 @@ def test_perform_success_zero_decimal_currency(env, factory, monkeypatch):
req.session = {}
prov.checkout_prepare(req, {})
assert 'payment_stripe_token' in req.session
prov.payment_perform(req, order)
payment = order.payments.create(
provider='stripe_cc', amount=order.total
)
prov.execute_payment(req, payment)
order.refresh_from_db()
assert order.status == Order.STATUS_PAID
@@ -125,7 +136,10 @@ def test_perform_card_error(env, factory, monkeypatch):
prov.checkout_prepare(req, {})
assert 'payment_stripe_token' in req.session
with pytest.raises(PaymentException):
prov.payment_perform(req, order)
payment = order.payments.create(
provider='stripe_cc', amount=order.total
)
prov.execute_payment(req, payment)
order.refresh_from_db()
assert order.status == Order.STATUS_PENDING
@@ -148,7 +162,10 @@ def test_perform_stripe_error(env, factory, monkeypatch):
prov.checkout_prepare(req, {})
assert 'payment_stripe_token' in req.session
with pytest.raises(PaymentException):
prov.payment_perform(req, order)
payment = order.payments.create(
provider='stripe_cc', amount=order.total
)
prov.execute_payment(req, payment)
order.refresh_from_db()
assert order.status == Order.STATUS_PENDING
@@ -175,7 +192,10 @@ def test_perform_failed(env, factory, monkeypatch):
prov.checkout_prepare(req, {})
assert 'payment_stripe_token' in req.session
with pytest.raises(PaymentException):
prov.payment_perform(req, order)
payment = order.payments.create(
provider='stripe_cc', amount=order.total
)
prov.execute_payment(req, payment)
order.refresh_from_db()
assert order.status == Order.STATUS_PENDING
@@ -185,26 +205,26 @@ def test_refund_success(env, factory, monkeypatch):
event, order = env
def charge_retr(*args, **kwargs):
def refund_create():
def refund_create(amount):
pass
c = MockedCharge()
c.refunds = MockedCharge()
c.refunds.create = refund_create
return c
monkeypatch.setattr("stripe.Charge.retrieve", charge_retr)
order.status = Order.STATUS_PAID
order.payment_info = json.dumps({
p = order.payments.create(provider='stripe_cc', amount=order.total, info=json.dumps({
'id': 'ch_123345345'
})
}))
order.save()
prov = StripeCC(event)
req = factory.post('/', data={'auto_refund': 'auto'})
req.user = None
prov.order_control_refund_perform(req, order)
order.refresh_from_db()
assert order.status == Order.STATUS_REFUNDED
refund = order.refunds.create(
provider='stripe_cc', amount=order.total, payment=p,
)
prov.execute_refund(refund)
refund.refresh_from_db()
assert refund.state == OrderRefund.REFUND_STATE_DONE
@pytest.mark.django_db
@@ -212,23 +232,24 @@ def test_refund_unavailable(env, factory, monkeypatch):
event, order = env
def charge_retr(*args, **kwargs):
def refund_create():
def refund_create(amount):
raise APIConnectionError(message='Foo')
c = MockedCharge()
c.refunds = object()
c.refunds.create = refund_create()
c.refunds.create = refund_create
return c
monkeypatch.setattr("stripe.Charge.retrieve", charge_retr)
order.status = Order.STATUS_PAID
order.payment_info = json.dumps({
p = order.payments.create(provider='stripe_cc', amount=order.total, info=json.dumps({
'id': 'ch_123345345'
})
}))
order.save()
prov = StripeCC(event)
req = factory.get('/')
req.user = None
prov.order_control_refund_perform(req, order)
order.refresh_from_db()
assert order.status == Order.STATUS_PAID
refund = order.refunds.create(
provider='stripe_cc', amount=order.total, payment=p
)
with pytest.raises(PaymentException):
prov.execute_refund(refund)
refund.refresh_from_db()
assert refund.state != OrderRefund.REFUND_STATE_DONE

View File

@@ -6,7 +6,7 @@ import pytest
from django.utils.timezone import now
from pretix.base.models import (
Event, Order, Organizer, RequiredAction, Team, User,
Event, Order, OrderPayment, OrderRefund, Organizer, Team, User,
)
from pretix.plugins.stripe.models import ReferencedStripeObject
@@ -26,7 +26,7 @@ def env():
code='FOOBAR', event=event, email='dummy@dummy.test',
status=Order.STATUS_PAID,
datetime=now(), expires=now() + timedelta(days=10),
total=Decimal('13.37'), payment_provider='stripe'
total=Decimal('13.37'),
)
return event, o1
@@ -129,7 +129,7 @@ def test_webhook_all_good(env, client, monkeypatch):
@pytest.mark.django_db
def test_webhook_mark_paid(env, client, monkeypatch):
def test_webhook_mark_paid_without_reference_and_payment(env, client, monkeypatch):
order = env[1]
order.status = Order.STATUS_PENDING
order.save()
@@ -164,6 +164,13 @@ def test_webhook_mark_paid(env, client, monkeypatch):
@pytest.mark.django_db
def test_webhook_partial_refund(env, client, monkeypatch):
charge = get_test_charge(env[1])
payment = env[1].payments.create(
provider='stripe', amount=env[1].total, info=json.dumps(charge)
)
ReferencedStripeObject.objects.create(order=env[1], reference="ch_18TY6GGGWE2Ias8TZHanef25",
payment=payment)
charge['refunds'] = {
"object": "list",
"data": [
@@ -209,13 +216,10 @@ def test_webhook_partial_refund(env, client, monkeypatch):
order.refresh_from_db()
assert order.status == Order.STATUS_PAID
ra = RequiredAction.objects.get(action_type="pretix.plugins.stripe.refund")
client.login(username='dummy@dummy.dummy', password='dummy')
client.post('/control/event/dummy/dummy/stripe/refund/{}/'.format(ra.pk))
order = env[1]
order.refresh_from_db()
assert order.status == Order.STATUS_REFUNDED
ra = order.refunds.first()
assert ra.state == OrderRefund.REFUND_STATE_EXTERNAL
assert ra.source == 'external'
assert ra.amount == Decimal('123.00')
@pytest.mark.django_db
@@ -227,6 +231,48 @@ def test_webhook_global(env, client, monkeypatch):
charge = get_test_charge(env[1])
monkeypatch.setattr("stripe.Charge.retrieve", lambda *args, **kwargs: charge)
payment = order.payments.create(
provider='stripe', amount=order.total, info=json.dumps(charge), state=OrderPayment.PAYMENT_STATE_CREATED
)
ReferencedStripeObject.objects.create(order=order, reference="ch_18TY6GGGWE2Ias8TZHanef25",
payment=payment)
client.post('/_stripe/webhook/', json.dumps(
{
"id": "evt_18otImGGWE2Ias8TUyVRDB1G",
"object": "event",
"api_version": "2016-03-07",
"created": 1472729052,
"data": {
"object": {
"id": "ch_18TY6GGGWE2Ias8TZHanef25",
"object": "charge",
# Rest of object is ignored anway
}
},
"livemode": True,
"pending_webhooks": 1,
"request": "req_977XOWC8zk51Z9",
"type": "charge.succeeded"
}
), content_type='application_json')
order.refresh_from_db()
assert order.status == Order.STATUS_PAID
@pytest.mark.django_db
def test_webhook_global_legacy_reference(env, client, monkeypatch):
order = env[1]
order.status = Order.STATUS_PENDING
order.save()
charge = get_test_charge(env[1])
monkeypatch.setattr("stripe.Charge.retrieve", lambda *args, **kwargs: charge)
payment = order.payments.create(
provider='stripe', amount=order.total, info=json.dumps(charge), state=OrderPayment.PAYMENT_STATE_CREATED
)
ReferencedStripeObject.objects.create(order=order, reference="ch_18TY6GGGWE2Ias8TZHanef25")
client.post('/_stripe/webhook/', json.dumps(
@@ -251,3 +297,4 @@ def test_webhook_global(env, client, monkeypatch):
order.refresh_from_db()
assert order.status == Order.STATUS_PAID
assert list(order.payments.all()) == [payment]

View File

@@ -39,7 +39,7 @@ def order(item):
o = Order.objects.create(event=item.event, status=Order.STATUS_PENDING,
expires=now() + datetime.timedelta(hours=1),
total=13, code='DUMMY', email='dummy@dummy.test',
datetime=now(), payment_provider='banktransfer', locale='en')
datetime=now(), locale='en')
OrderPosition.objects.create(order=o, item=item, price=13)
return o
@@ -148,7 +148,7 @@ def test_sendmail_multi_locales(logged_in_client, sendmail_url, event, item):
o = Order.objects.create(event=item.event, status=Order.STATUS_PAID,
expires=now() + datetime.timedelta(hours=1),
total=13, code='DUMMY', email='dummy@dummy.test',
datetime=now(), payment_provider='banktransfer',
datetime=now(),
locale='de')
OrderPosition.objects.create(order=o, item=item, price=13)

View File

@@ -23,7 +23,7 @@ def env0():
code='FOOBAR', event=event, email='dummy@dummy.test',
status=Order.STATUS_PENDING,
datetime=now(), expires=now() + timedelta(days=10),
total=Decimal('13.37'), payment_provider='banktransfer'
total=Decimal('13.37'),
)
shirt = Item.objects.create(event=event, name='T-Shirt', default_price=12)
shirt_red = ItemVariation.objects.create(item=shirt, default_price=14, value="Red")