Change semantics of changing orders (#1260)

* Change semantics of changing orders

This basically does two things to the "Change products" view of orders and the
OrderChangeManager program API:

1) It decouples changing items or subevents from changing prices.
   OrderChangeManager.change_item() and .change_subevent() no longer
   touch the price of a position. Instead .change_price() needs to be
   called explicitly. However, a client-side JavaScript component now
   *proposes* a new price based on the changed item or subevent.

2) The user interface now exposes the possibility of doing multiple
   things at the same time, i.e. changing the item, subevent and price
   in the same operation. OrderChangeManager already allowed this
   before.

(1) is basically a consequence of (2), while (2) is a prerequesite for
e.g. the `seating` branch, where changing the subevent will always
require changing the seat.

* Add tests for price calculation API
This commit is contained in:
Raphael Michel
2019-04-30 09:51:19 +02:00
committed by GitHub
parent df3e6f4b9a
commit a6c72abe75
13 changed files with 531 additions and 251 deletions

View File

@@ -2798,3 +2798,222 @@ def test_order_resend_link(token_client, organizer, event, order):
), format='json', data={}
)
assert resp.status_code == 400
@pytest.mark.django_db
def test_orderposition_price_calculation(token_client, organizer, event, order, item):
op = order.positions.first()
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orderpositions/{}/price_calc/'.format(organizer.slug, event.slug, op.pk),
data={
}
)
assert resp.status_code == 200
assert resp.data == {
'gross': Decimal('23.00'),
'gross_formatted': '23.00',
'name': '',
'net': Decimal('23.00'),
'rate': Decimal('0.00'),
'tax': Decimal('0.00')
}
@pytest.mark.django_db
def test_orderposition_price_calculation_item_with_tax(token_client, organizer, event, order, item, taxrule):
item2 = event.items.create(name="Budget Ticket", default_price=23, tax_rule=taxrule)
op = order.positions.first()
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orderpositions/{}/price_calc/'.format(organizer.slug, event.slug, op.pk),
data={
'item': item2.pk
}
)
assert resp.status_code == 200
assert resp.data == {
'gross': Decimal('23.00'),
'gross_formatted': '23.00',
'name': '',
'net': Decimal('19.33'),
'rate': Decimal('19.00'),
'tax': Decimal('3.67')
}
@pytest.mark.django_db
def test_orderposition_price_calculation_item_with_variation(token_client, organizer, event, order):
item2 = event.items.create(name="Budget Ticket", default_price=23)
var = item2.variations.create(default_price=12, value="XS")
op = order.positions.first()
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orderpositions/{}/price_calc/'.format(organizer.slug, event.slug, op.pk),
data={
'item': item2.pk,
'variation': var.pk
}
)
assert resp.status_code == 200
assert resp.data == {
'gross': Decimal('12.00'),
'gross_formatted': '12.00',
'name': '',
'net': Decimal('12.00'),
'rate': Decimal('0.00'),
'tax': Decimal('0.00')
}
@pytest.mark.django_db
def test_orderposition_price_calculation_subevent(token_client, organizer, event, order, subevent):
item2 = event.items.create(name="Budget Ticket", default_price=23)
op = order.positions.first()
op.subevent = subevent
op.save()
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orderpositions/{}/price_calc/'.format(organizer.slug, event.slug, op.pk),
data={
'item': item2.pk,
'subevent': subevent.pk
}
)
assert resp.status_code == 200
assert resp.data == {
'gross': Decimal('23.00'),
'gross_formatted': '23.00',
'name': '',
'net': Decimal('23.00'),
'rate': Decimal('0.00'),
'tax': Decimal('0.00')
}
@pytest.mark.django_db
def test_orderposition_price_calculation_subevent_with_override(token_client, organizer, event, order, subevent):
item2 = event.items.create(name="Budget Ticket", default_price=23)
se2 = event.subevents.create(name="Foobar", date_from=datetime.datetime(2017, 12, 27, 10, 0, 0, tzinfo=UTC))
se2.subeventitem_set.create(item=item2, price=12)
op = order.positions.first()
op.subevent = subevent
op.save()
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orderpositions/{}/price_calc/'.format(organizer.slug, event.slug, op.pk),
data={
'item': item2.pk,
'subevent': se2.pk
}
)
assert resp.status_code == 200
assert resp.data == {
'gross': Decimal('12.00'),
'gross_formatted': '12.00',
'name': '',
'net': Decimal('12.00'),
'rate': Decimal('0.00'),
'tax': Decimal('0.00')
}
@pytest.mark.django_db
def test_orderposition_price_calculation_voucher_matching(token_client, organizer, event, order, subevent, item):
item2 = event.items.create(name="Budget Ticket", default_price=23)
q = event.quotas.create(name="Quota")
q.items.add(item)
q.items.add(item2)
voucher = event.vouchers.create(price_mode="set", value=15, quota=q)
op = order.positions.first()
op.voucher = voucher
op.save()
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orderpositions/{}/price_calc/'.format(organizer.slug, event.slug, op.pk),
data={
'item': item2.pk,
}
)
assert resp.status_code == 200
assert resp.data == {
'gross': Decimal('15.00'),
'gross_formatted': '15.00',
'name': '',
'net': Decimal('15.00'),
'rate': Decimal('0.00'),
'tax': Decimal('0.00')
}
@pytest.mark.django_db
def test_orderposition_price_calculation_voucher_not_matching(token_client, organizer, event, order, subevent, item):
item2 = event.items.create(name="Budget Ticket", default_price=23)
q = event.quotas.create(name="Quota")
q.items.add(item)
voucher = event.vouchers.create(price_mode="set", value=15, quota=q)
op = order.positions.first()
op.voucher = voucher
op.save()
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orderpositions/{}/price_calc/'.format(organizer.slug, event.slug, op.pk),
data={
'item': item2.pk,
}
)
assert resp.status_code == 200
assert resp.data == {
'gross': Decimal('23.00'),
'gross_formatted': '23.00',
'name': '',
'net': Decimal('23.00'),
'rate': Decimal('0.00'),
'tax': Decimal('0.00')
}
@pytest.mark.django_db
def test_orderposition_price_calculation_net_price(token_client, organizer, event, order, subevent, item, taxrule):
taxrule.price_includes_tax = False
taxrule.save()
item2 = event.items.create(name="Budget Ticket", default_price=10, tax_rule=taxrule)
op = order.positions.first()
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orderpositions/{}/price_calc/'.format(organizer.slug, event.slug, op.pk),
data={
'item': item2.pk,
}
)
assert resp.status_code == 200
assert resp.data == {
'gross': Decimal('11.90'),
'gross_formatted': '11.90',
'name': '',
'net': Decimal('10.00'),
'rate': Decimal('19.00'),
'tax': Decimal('1.90')
}
@pytest.mark.django_db
def test_orderposition_price_calculation_reverse_charge(token_client, organizer, event, order, subevent, item, taxrule):
taxrule.price_includes_tax = False
taxrule.eu_reverse_charge = True
taxrule.home_country = Country('DE')
taxrule.save()
order.invoice_address.is_business = True
order.invoice_address.vat_id = 'ATU1234567'
order.invoice_address.vat_id_validated = True
order.invoice_address.country = Country('AT')
order.invoice_address.save()
item2 = event.items.create(name="Budget Ticket", default_price=10, tax_rule=taxrule)
op = order.positions.first()
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orderpositions/{}/price_calc/'.format(organizer.slug, event.slug, op.pk),
data={
'item': item2.pk,
}
)
assert resp.status_code == 200
assert resp.data == {
'gross': Decimal('10.00'),
'gross_formatted': '10.00',
'name': '',
'net': Decimal('10.00'),
'rate': Decimal('0.00'),
'tax': Decimal('0.00')
}

View File

@@ -26,6 +26,7 @@ event_permission_sub_urls = [
('get', 'can_view_orders', 'orders/', 200),
('get', 'can_view_orders', 'orderpositions/', 200),
('delete', 'can_change_orders', 'orderpositions/1/', 404),
('post', 'can_change_orders', 'orderpositions/1/price_calc/', 404),
('get', 'can_view_vouchers', 'vouchers/', 200),
('get', 'can_view_orders', 'invoices/', 200),
('get', 'can_view_orders', 'invoices/1/', 404),

View File

@@ -611,49 +611,27 @@ class OrderChangeManagerTests(TestCase):
self.op1.refresh_from_db()
self.order.refresh_from_db()
assert self.op1.subevent == se2
assert self.op1.price == 12
assert self.op1.price == Decimal('23.00')
assert self.order.total == self.op1.price + self.op2.price
def test_change_subevent_reverse_charge(self):
self._enable_reverse_charge()
def test_change_subevent_with_price_success(self):
self.event.has_subevents = True
self.event.save()
se1 = self.event.subevents.create(name="Foo", date_from=now())
se2 = self.event.subevents.create(name="Bar", date_from=now())
SubEventItem.objects.create(subevent=se2, item=self.ticket, price=10.7)
SubEventItem.objects.create(subevent=se2, item=self.ticket, price=12)
self.op1.subevent = se1
self.op1.save()
self.quota.subevent = se2
self.quota.save()
self.ocm.change_subevent(self.op1, se2)
self.ocm.change_price(self.op1, Decimal('12.00'))
self.ocm.commit()
self.op1.refresh_from_db()
self.order.refresh_from_db()
assert self.op1.subevent == se2
assert self.op1.price == Decimal('10.00')
assert self.op1.tax_value == Decimal('0.00')
assert self.order.total == self.op1.price + self.op2.price
def test_change_subevent_net_price(self):
self.event.has_subevents = True
self.event.save()
se1 = self.event.subevents.create(name="Foo", date_from=now())
se2 = self.event.subevents.create(name="Bar", date_from=now())
self.tr7.price_includes_tax = False
self.tr7.save()
SubEventItem.objects.create(subevent=se2, item=self.ticket, price=10)
self.op1.subevent = se1
self.op1.save()
self.quota.subevent = se2
self.quota.save()
self.ocm.change_subevent(self.op1, se2)
self.ocm.commit()
self.op1.refresh_from_db()
self.order.refresh_from_db()
assert self.op1.subevent == se2
assert self.op1.price == Decimal('10.70')
assert self.op1.price == Decimal('12.00')
assert self.order.total == self.op1.price + self.op2.price
def test_change_subevent_sold_out(self):
@@ -680,14 +658,14 @@ class OrderChangeManagerTests(TestCase):
def test_change_item_keep_price(self):
p = self.op1.price
tv = self.op1.tax_value
self.ocm.change_item(self.op1, self.shirt, None, keep_price=True)
self.ocm.change_item(self.op1, self.shirt, None)
self.ocm.commit()
self.op1.refresh_from_db()
self.order.refresh_from_db()
assert self.op1.item == self.shirt
assert self.op1.price == p
assert self.op1.tax_value == tv
assert self.op1.tax_value == Decimal('3.67')
assert self.op1.tax_rule == self.shirt.tax_rule
def test_change_item_success(self):
self.ocm.change_item(self.op1, self.shirt, None)
@@ -695,36 +673,23 @@ class OrderChangeManagerTests(TestCase):
self.op1.refresh_from_db()
self.order.refresh_from_db()
assert self.op1.item == self.shirt
assert self.op1.price == self.shirt.default_price
assert self.op1.price == Decimal('23.00')
assert self.op1.tax_rate == self.shirt.tax_rule.rate
assert round_decimal(self.op1.price * (1 - 100 / (100 + self.op1.tax_rate))) == self.op1.tax_value
assert self.order.total == self.op1.price + self.op2.price
def test_change_item_net_price_success(self):
self.tr19.price_includes_tax = False
self.tr19.save()
def test_change_item_with_price_success(self):
self.ocm.change_item(self.op1, self.shirt, None)
self.ocm.change_price(self.op1, Decimal('12.00'))
self.ocm.commit()
self.op1.refresh_from_db()
self.order.refresh_from_db()
assert self.op1.item == self.shirt
assert self.op1.price == Decimal('14.28')
assert self.op1.price == Decimal('12.00')
assert self.op1.tax_rate == self.shirt.tax_rule.rate
assert round_decimal(self.op1.price * (1 - 100 / (100 + self.op1.tax_rate))) == self.op1.tax_value
assert self.order.total == self.op1.price + self.op2.price
def test_change_item_reverse_charge(self):
self._enable_reverse_charge()
self.ocm.change_item(self.op1, self.shirt, None)
self.ocm.commit()
self.op1.refresh_from_db()
self.order.refresh_from_db()
assert self.op1.item == self.shirt
assert self.op1.price == Decimal('10.08')
assert self.op1.tax_rate == Decimal('0.00')
assert self.op1.tax_value == Decimal('0.00')
assert self.order.total == self.op1.price + self.op2.price
def test_change_price_success(self):
self.ocm.change_price(self.op1, Decimal('24.00'))
self.ocm.commit()
@@ -738,7 +703,7 @@ class OrderChangeManagerTests(TestCase):
def test_change_price_net_success(self):
self.tr7.price_includes_tax = False
self.tr7.save()
self.ocm.change_price(self.op1, Decimal('10.00'))
self.ocm.change_price(self.op1, Decimal('10.70'))
self.ocm.commit()
self.op1.refresh_from_db()
self.order.refresh_from_db()

View File

@@ -935,11 +935,9 @@ class OrderChangeTests(SoupTest):
self.client.post('/control/event/{}/{}/orders/{}/change'.format(
self.event.organizer.slug, self.event.slug, self.order.code
), {
'op-{}-operation'.format(self.op1.pk): 'product',
'op-{}-itemvar'.format(self.op1.pk): str(self.shirt.pk),
'op-{}-operation'.format(self.op2.pk): '',
'op-{}-itemvar'.format(self.op2.pk): str(self.ticket.pk),
'add-itemvar'.format(self.op2.pk): str(self.ticket.pk),
'op-{}-price'.format(self.op1.pk): str('12.00'),
'add-itemvar': str(self.ticket.pk),
})
self.op1.refresh_from_db()
self.order.refresh_from_db()
@@ -965,14 +963,9 @@ class OrderChangeTests(SoupTest):
self.client.post('/control/event/{}/{}/orders/{}/change'.format(
self.event.organizer.slug, self.event.slug, self.order.code
), {
'op-{}-operation'.format(self.op1.pk): 'subevent',
'op-{}-subevent'.format(self.op1.pk): str(se2.pk),
'op-{}-itemvar'.format(self.op1.pk): str(self.ticket.pk),
'op-{}-operation'.format(self.op2.pk): '',
'op-{}-itemvar'.format(self.op2.pk): str(self.ticket.pk),
'op-{}-subevent'.format(self.op2.pk): str(se1.pk),
'add-itemvar'.format(self.op2.pk): str(self.ticket.pk),
'add-subevent'.format(self.op2.pk): str(se1.pk),
'add-itemvar': str(self.ticket.pk),
'add-subevent': str(se1.pk),
})
self.op1.refresh_from_db()
self.op2.refresh_from_db()
@@ -989,7 +982,7 @@ class OrderChangeTests(SoupTest):
'op-{}-price'.format(self.op1.pk): '24.00',
'op-{}-operation'.format(self.op2.pk): '',
'op-{}-itemvar'.format(self.op2.pk): str(self.ticket.pk),
'add-itemvar'.format(self.op2.pk): str(self.ticket.pk),
'add-itemvar': str(self.ticket.pk),
})
self.op1.refresh_from_db()
self.order.refresh_from_db()
@@ -1001,13 +994,8 @@ class OrderChangeTests(SoupTest):
self.client.post('/control/event/{}/{}/orders/{}/change'.format(
self.event.organizer.slug, self.event.slug, self.order.code
), {
'op-{}-operation'.format(self.op1.pk): 'cancel',
'op-{}-itemvar'.format(self.op1.pk): str(self.ticket.pk),
'op-{}-price'.format(self.op1.pk): str(self.op1.price),
'op-{}-operation'.format(self.op2.pk): '',
'op-{}-itemvar'.format(self.op2.pk): str(self.ticket.pk),
'op-{}-price'.format(self.op2.pk): str(self.op2.price),
'add-itemvar'.format(self.op2.pk): str(self.ticket.pk),
'op-{}-operation_cancel'.format(self.op1.pk): 'on',
'add-itemvar': str(self.ticket.pk),
})
self.order.refresh_from_db()
assert self.order.positions.count() == 1
@@ -1017,12 +1005,6 @@ class OrderChangeTests(SoupTest):
self.client.post('/control/event/{}/{}/orders/{}/change'.format(
self.event.organizer.slug, self.event.slug, self.order.code
), {
'op-{}-operation'.format(self.op1.pk): '',
'op-{}-operation'.format(self.op2.pk): '',
'op-{}-itemvar'.format(self.op2.pk): str(self.ticket.pk),
'op-{}-price'.format(self.op2.pk): str(self.op2.price),
'op-{}-itemvar'.format(self.op1.pk): str(self.ticket.pk),
'op-{}-price'.format(self.op1.pk): str(self.op1.price),
'add-itemvar': str(self.shirt.pk),
'add-do': 'on',
'add-price': '14.00',
@@ -1047,7 +1029,7 @@ class OrderChangeTests(SoupTest):
self.event.organizer.slug, self.event.slug, self.order.code
), {
'other-recalculate_taxes': 'on',
'add-itemvar'.format(self.op2.pk): str(self.ticket.pk),
'add-itemvar': str(self.ticket.pk),
'op-{}-operation'.format(self.op1.pk): '',
'op-{}-operation'.format(self.op2.pk): '',
'op-{}-itemvar'.format(self.op2.pk): str(self.ticket.pk),