forked from CGM_Public/pretix_original
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:
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user