From 3e972eddbf117a17da33e730f9e3fc8bf4f59306 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 30 Oct 2025 11:49:31 +0100 Subject: [PATCH] Allow to round taxes on order-level (#5019) * Allow to round taxes on order-level * Rename get_cart_total * Persist rounding mode with order * Add general docs * Order creation API * Update fee algorithm * Rounding on payment method change * Round when splitting order * Fix failing tests * Add settings page * Add tests * Replace algorithm * Add test case for currency rounding * Improve order change * Update flowchart * Update discount logic (more hypothetical, we don't store rounding on cart positions atm) * Rename internal method * Fix typo * Update help text * Apply suggestions from code review Co-authored-by: luelista * Order rounding refactor (#5571) * Add RoundingCorrectionMixin providing before-rounding-values as properties * Use gross_price_before_rounding in more places * Update doc/development/algorithms/pricing.rst Co-authored-by: Martin Gross * Allow to override on perform_order * Rebase migration * Fix event cancellation --------- Co-authored-by: luelista Co-authored-by: Martin Gross --- doc/api/resources/orders.rst | 8 + doc/development/algorithms/pricing.rst | 121 +++++++++ doc/images/cart_pricing.png | Bin 55406 -> 49918 bytes doc/images/cart_pricing.puml | 1 + src/pretix/api/serializers/event.py | 1 + src/pretix/api/serializers/order.py | 54 +++- src/pretix/api/views/order.py | 1 + ...e_includes_rounding_correction_and_more.py | 81 ++++++ src/pretix/base/models/orders.py | 136 ++++++++-- src/pretix/base/payment.py | 12 +- src/pretix/base/services/cancelevent.py | 2 +- src/pretix/base/services/cart.py | 70 +++-- src/pretix/base/services/orders.py | 234 ++++++++++------ src/pretix/base/services/pricing.py | 134 +++++++++- src/pretix/base/settings.py | 28 +- src/pretix/control/forms/event.py | 82 +++++- src/pretix/control/navigation.py | 2 +- .../pretixcontrol/event/settings.html | 1 - .../templates/pretixcontrol/event/tax.html | 120 +++++++++ .../pretixcontrol/event/tax_index.html | 77 ------ .../templates/pretixcontrol/order/index.html | 24 ++ .../pretixcontrol/order/transactions.html | 22 +- src/pretix/control/urls.py | 2 +- src/pretix/control/views/event.py | 33 ++- src/pretix/control/views/orders.py | 2 + src/pretix/plugins/paypal2/views.py | 11 +- src/pretix/presale/checkoutflow.py | 51 ++-- src/pretix/presale/views/__init__.py | 133 +++++++--- src/pretix/presale/views/order.py | 21 +- src/tests/api/test_order_create.py | 77 ++++++ src/tests/api/test_orders.py | 1 + src/tests/base/test_orders.py | 136 +++++++++- src/tests/base/test_pricing_rounding.py | 247 +++++++++++++++++ src/tests/base/test_taxrules.py | 15 +- src/tests/presale/test_checkout.py | 250 +++++++++++++++++- src/tests/presale/test_orders.py | 50 ++++ src/tests/testdummy/payment.py | 2 +- 37 files changed, 1923 insertions(+), 319 deletions(-) create mode 100644 src/pretix/base/migrations/0293_cartposition_price_includes_rounding_correction_and_more.py create mode 100644 src/pretix/control/templates/pretixcontrol/event/tax.html delete mode 100644 src/pretix/control/templates/pretixcontrol/event/tax_index.html create mode 100644 src/tests/base/test_pricing_rounding.py diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index 2894d16c6e..f863a6df45 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -41,6 +41,7 @@ expires datetime The order will payment_date date **DEPRECATED AND INACCURATE** Date of payment receipt payment_provider string **DEPRECATED AND INACCURATE** Payment provider used for this order total money (string) Total value of this order +tax_rounding_mode string Tax rounding mode, see :ref:`algorithms-rounding` comment string Internal comment on this order api_meta object Meta data for that order. Only available through API, no guarantees on the content structure. You can use this to save references to your system. @@ -151,6 +152,10 @@ plugin_data object Additional data The ``invoice_address.transmission_type`` and ``invoice_address.transmission_info`` attributes have been added. +.. versionchanged:: 2025.10 + + The ``tax_rounding_mode`` attribute has been added. + .. _order-position-resource: Order position resource @@ -358,6 +363,7 @@ List of all orders "payment_provider": "banktransfer", "fees": [], "total": "23.00", + "tax_rounding_mode": "line", "comment": "", "custom_followup_at": null, "checkin_attention": false, @@ -602,6 +608,7 @@ Fetching individual orders "payment_provider": "banktransfer", "fees": [], "total": "23.00", + "tax_rounding_mode": "line", "comment": "", "api_meta": {}, "custom_followup_at": null, @@ -1011,6 +1018,7 @@ Creating orders provider will not be called to do anything about this (i.e. if you pass a bank account to a debit provider, *no* charge will be created), this is just informative in case you *handled the payment already*. * ``payment_date`` (optional) – Date and time of the completion of the payment. + * ``tax_rounding_mode`` (optional) * ``comment`` (optional) * ``custom_followup_at`` (optional) * ``checkin_attention`` (optional) diff --git a/doc/development/algorithms/pricing.rst b/doc/development/algorithms/pricing.rst index 1b88633894..aa1c7769fc 100644 --- a/doc/development/algorithms/pricing.rst +++ b/doc/development/algorithms/pricing.rst @@ -178,3 +178,124 @@ Flowchart --------- .. image:: /images/cart_pricing.png + + +.. _`algorithms-rounding`: + +Rounding of taxes +----------------- + +pretix internally always stores taxes on a per-line level, like this: + + ========== ========== =========== ======= ============= + Product Tax rate Net price Tax Gross price + ========== ========== =========== ======= ============= + Ticket A 19 % 84.03 15.97 100.00 + Ticket B 19 % 84.03 15.97 100.00 + Ticket C 19 % 84.03 15.97 100.00 + Ticket D 19 % 84.03 15.97 100.00 + Ticket E 19 % 84.03 15.97 100.00 + Sum 420.15 79.85 500.00 + ========== ========== =========== ======= ============= + +Whether the net price is computed from the gross price or vice versa is configured on the tax rule and may differ for every line. + +The line-based computation has a few significant advantages: + +- We can report both net and gross prices for every individual ticket. + +- We can report both net and gross prices for every filter imaginable, such as the gross sum of all sales of Ticket A + or the net sum of all sales for a specific date in an event series. All numbers will be exact. + +- When splitting the order into two, both net price and gross price are split without any changes in rounding. + +The main disadvantage is that the tax looks "wrong" when computed from the sum. Taking the sum of net prices (420.15) +and multiplying it with the tax rate (19%) yields a tax amount of 79.83 (instead of 79.85) and a gross sum of 499.98 +(instead of 499.98). This becomes a problem when juristictions, data formats, or external systems expect this calculation +to work on the level of the entire order. A prominent example is the EN 16931 standard for e-invoicing that +does not allow the computation as created by pretix. + +However, calculating the tax rate from the net total has significant disadvantages: + +- It is impossible to guarantee a stable gross price this way, i.e. if you advertise a price of €100 per ticket to + consumers, they will be confused when they only need to pay €499.98 for 5 tickets. + +- Some prices are impossible, e.g. you cannot sell a ticket for a gross price of €99.99 at a 19% tax rate, since there + is no two-decimal net price that would be computed to a gross price of €99.99. + +- When splitting an order into two, the combined of the new orders is not guaranteed to be the same as the total of the + original order. Therefore, additional payments or refunds of very small amounts might be necessary. + +To allow organizers to make their own choices on this matter, pretix provides the following options: + +Compute taxes for every line individually +""""""""""""""""""""""""""""""""""""""""" + +Algorithm identifier: ``line`` + +This is our original algorithm where the tax value is rounded for every line individually. + +**This is our current default algorithm and we recommend it whenever you do not have different requirements** (see below). +For the example above: + + ========== ========== =========== ======= ============= + Product Tax rate Net price Tax Gross price + ========== ========== =========== ======= ============= + Ticket A 19 % 84.03 15.97 100.00 + Ticket B 19 % 84.03 15.97 100.00 + Ticket C 19 % 84.03 15.97 100.00 + Ticket D 19 % 84.03 15.97 100.00 + Ticket E 19 % 84.03 15.97 100.00 + Sum 420.15 79.85 500.00 + ========== ========== =========== ======= ============= + + +Compute taxes based on net total +"""""""""""""""""""""""""""""""" + +Algorithm identifier: ``sum_by_net`` + +In this algorithm, the tax value and gross total are computed from the sum of the net prices. To accomplish this within +our data model, the gross price and tax of some of the tickets will be changed by the minimum currency unit (e.g. €0.01). +The net price of the tickets always stay the same. + +**This is the algorithm intended by EN 16931 invoices and our recommendation to use for e-invoicing when (primarily) business customers are involved.** + +The main downside is that it might be confusing when selling to consumers, since the amounts to be paid change in unexpected ways. +For the example above, the customer expects to pay 5 times 100.00, but they are are in fact charged 499.98: + + ========== ========== =========== ============================== ============================== + Product Tax rate Net price Tax Gross price + ========== ========== =========== ============================== ============================== + Ticket A 19 % 84.03 15.96 (incl. -0.01 rounding) 99.99 (incl. -0.01 rounding) + Ticket B 19 % 84.03 15.96 (incl. -0.01 rounding) 99.99 (incl. -0.01 rounding) + Ticket C 19 % 84.03 15.97 100.00 + Ticket D 19 % 84.03 15.97 100.00 + Ticket E 19 % 84.03 15.97 100.00 + Sum 420.15 78.83 499.98 + ========== ========== =========== ============================== ============================== + +Compute taxes based on net total with stable gross prices +""""""""""""""""""""""""""""""""""""""""""""""""""""""""" + +Algorithm identifier: ``sum_by_net_keep_gross`` + +In this algorithm, the tax value and gross total are computed from the sum of the net prices. However, the net prices +of some of the tickets will be changed automatically by the minimum currency unit (e.g. €0.01) such that the resulting +gross prices stay the same. + +**This is less confusing to consumers and the end result is still compliant to EN 16931, so we recommend this for e-invoicing when (primarily) consumers are involved.** + +The main downside is that it might be confusing when selling to business customers, since the prices of the identical tickets appear to be different. +Full computation for the example above: + + ========== ========== ============================= ============================== ============= + Product Tax rate Net price Tax Gross price + ========== ========== ============================= ============================== ============= + Ticket A 19 % 84.04 (incl. 0.01 rounding) 15.96 (incl. -0.01 rounding) 100.00 + Ticket B 19 % 84.04 (incl. 0.01 rounding) 15.96 (incl. -0.01 rounding) 100.00 + Ticket C 19 % 84.03 15.97 100.00 + Ticket D 19 % 84.03 15.97 100.00 + Ticket E 19 % 84.03 15.97 100.00 + Sum 420.17 79.83 500.00 + ========== ========== ============================= ============================== ============= diff --git a/doc/images/cart_pricing.png b/doc/images/cart_pricing.png index 4b1f3a7138c87f843f4c5bbed73adf258277aa5e..b2d667eeac87459d566bed90e38a13f2ad52214e 100644 GIT binary patch literal 49918 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

Pmi)U(i3bkzninrz zxBy9U0FHV=A@?>pa)zjSv_){C1f9;{eG;E{95U3}02dx=&3)s}y?c{F8J^Vo>jqun z=OqUDhB~R5TI*+scSL-nmQMN1<=40WU-q)gBAYg8sXlWJy~)70lc<;8S^^)x8WWSf z>n=squ%c7AV>7kpeHs%V{|R(-qIPE!x-BF*<)1y{)66GNGYVzE=h10sh2igYd)4(e0lLl<}*{(Zx#i+u7COY$7LrLl8M{pSnF zfY7N(+TM|7jb-Os{sN;5g@-?bC{s6Qfd(8pTwGi~zP_L(#_M%GwkN);#M5N_0uIMRMWu0CHVx^pMN>xmy# zNFYaW7ho>u8J4Nv#L1*m($e3F+SIIaIdL)Hqkj{ijlCjGxxrFpx``^*X*z&ol7w;M zaGfK~XIfd7X{#|+YGFETL5 zpnf@3+vaTUZYkpV%TEZ|-W+jf;ZxXZQ+Y9}FX+-e+QOc*M9CiL5J`d97=`ifst!=d zyEG6L_g@RiU)YbYeYmwB;lNeUBX;T!1pZk7Dx!b&B2pYNV)ue5LKq8iV;_R!$;rzr zla-XkVFVO*R@Pf4Sq&fxUZLSd@($r~`{;B0cwX!C)^umc=uW`;lYbK@`T|{5uH^hW zgdTw?S2HykM%NOOGE|J_n5D--3nc4a?$j{x|647`@gID4U}hzrJ{CVYEMq-W=)w&IEdl(lc|Vg3yf4cq4Ugcstpg)Ve9<(g}gW28Z}6UE^h z2MxC-z$J}}ov3Yj<(w@qmxcKvg|sOVJstnWDoT@#wPbRz3B)XLhPXY%T+WfPz=jr? z?72`bQFx7Q9W0}tpgQ8&P?v>-D#E{Iy~9bcFoJIZWdUnLs{e4D1*X{_bfV{zP7s_y zObmXu&I~IP)Bffr6J^neF5$1A5bntqb@}<2PzP%{*XU;KM8NnWWDlYdo-ojhG|Pml z+xax9axAP@D_79-2t3{97cN~2_?UP|!o7Y>h(}_E-~O9x<}VuDNkSnYEW zL8lJx0?+6KC=ho|mVMFGD2wl*BL?h^IF-{g(FUUQ1#b`zHKMTFKnW6?IJ5zVKaHA? z6okV1k~-Xi;aza0#G1+NP=xdR3p{9WF_O zM|#sblVngB9p_30Sh%%BR^G{t`PQCSY2PHTeK2Loda1RE{a zIKZvC$h3htOKB`^v)3=k6cFTfYfs150ECV(elXM@Cv_aw`{O)U*fW%u^TB&jT8HWkQxz6qEHNq|+><9Wo3e(BRh)<>>T3}<#po>O6h%CZpc!j9q# zQp;M^b$@i58KF_`a=4D`(qX9f>t-)FRt|^;Sdl*ft)VvnyGnZZY%O8P;4(l3+#Q$G zY=tSMxT*W{$c|$ERR-vGue4So&SoyP%fr@fKbbjW+L+Si7C1M&mO^^??P_k-<~}yQ z?Zp$AxWa|f`EuGHCeb29H*Kw*y)Ic37vokxrt$36ffK0D*V8e$IxA32A8{92_Oq3p z-&4b-e}Y>G^HLN#=5YDbE5e>)qM>pPzg>{65VQFUYhRB|W=Kvg6Yu$kZDa-i50@0d zo;>!{JG1mGnyoWwG+x;nLf6)BX~ifcw2i?w6lo3hC9oK}a~ z|7&-_n&eomaNXU{zfNJpoiI`H^(M&VP_#|Pk%tP^l$7QOEswK4llo7UmpV{UYOLQ_ z$?5z1f0X|HUm40BXEIuFc|x4r+?{-%DSI^fZpOxPcI8D?y3~&zPEa|>4AXH;II9jr z>{k1Ioe{lj+%ejs(rpOA33`fbe#SVQUZwfW5l!aLR)={X4}cGVz>S6h-nbn#uMhKZ z{`cVknfjorPB2++Pka(byby&b{}QBl^ycg%cT8<>6SJ#hF8haE@=%5iH`_`aW1;U3 zJ^wP6&IQPt;~58Hi8KMHLPC(wnZ0=ysw)^vi9}$=51uLCT6`uCChjt*M2{OQO`a0m zU0T6}8Lg4{B0}RGv3}NSm6Prl-#|{PxhyHqUDZe!%_AqgG!a)c^YhYji z;NHhCGsNT zt_}ubUD76r*FE&GFVqvLm5uy*ngI>RbtcTNBroaY+Rg62+{QprHXa$fY7PYuu{rEZ zjPpJ{pR=z_z(ldJ+;Mu^F@uGeK#`Pg|BzmXP8*ruZW3|N)|#K0eqqsiW}@@!kK>V& z?)BGRMFvX|*Sxndf1a`I;ZD-g9UE%++@)(zA_|zua4s|Qf3qR5s;89Vak7TzGo6d} z8p{_EOJ)JQ*xeBv3s0VQ~Lk>HeGO;wW-k*kpP3tb%rG8!7lk7eI%QGTN$fq zaL2Dzm~QrxK34QBsg{qQi%Z1URBsu~st6sDuUc(L(jOmb`m^TX+<$%%_uGk=Znl@d z4LC5KqQwV{x@$*yNz>O5i)dWM^)AJP;C;m1_~%&_-=j$Uq^yhBq#*f+R(;OwQ1gmE z9vPRmom%+CfVWdBO0g#as)1txhj=wXeIg(5J4{QgO48r2a;K$bRA=t)kgKlsk9v=> zFRf?LGU0harQt`RyhKF`7eq3&FB5VqmrW0oJH9qV#gr!R0mKJc*isgT~OnuAqO2c;DP40??x(rFd$xrWr+T(79~Ppx|HmtHfUJ(Nz1ESx-GN8E34=Ay?y2&q)3 z6H@Bf)tzc#t(DFpGFpVlxCsqI=XQ-;Ym;BXH_!i&cM49YrSz4D+QpsHK}x41;MsSd zA`&SrwckAUizyvuE;`=a-cZ-NFqIxUovfm4)3$F{;DF^?byCs;0(3GXKquEqdH##M z8v{Zk=GmOh3LB}n&Fbx;pg0B3{T4ZgWV(-Jm@sik%>R!|kpBJ873?@I^6T#v{6&ev z-ru@4WmtT_xfsyONLb5)f6;;9J4h7Tz1fK`|LXjY}^;EYXxQRw|^p8}$x4*i5} z2%-_~bcW9a+y=GDL}Bj+qQ`U$1EUUY%bIqpASpQ65%i*bYD#qQ@>eRJ5&`+|6%>f-_bf+Z?R01dCj|e}4x~f_NqoLxBNs1mQy!fo;r7n8t&5_n|BT z!EnHNzjbf%el4(wP*y!@XD(l|KfHtYEXIJ3?4s_RNvD(px(_2^5A>Xe82XlwD@F*L zk%C3$gZjmo$e{XfkO@w+)FV#Ol)n^Bfox_$=?alT>m8q6!S15|AUMqAc(n3 ze|(?@3E^{Y8GETnnD`N3%&2HUkRYEZrRm?7PoDtr_e=t8+dEQF7;(8d%Mj9AUtdqf zN><1~YmM6axyywj8U=MWFVKFFHi-Y6CO)<#9BT_Y<9I1IefG0>4-v+R5U(P3s>ERT!9$0LBiQg< z=JztF)5F$&j8K5ztWYFALsZi+5&1lrEP*n#9jxShponi_U?6^V{SNX><{9P`x^f(N z=ze*FN>i1?Z(=}4oWxJTnOAcxD}pvdpM471X@eJ*W|=d1a99x;0!L^%#DQkR>gK<_ zat-la3H!mj&DL>rtVK`z@pVZmu?mFdfpCD1xXy*fB^mD}$VmCu^aS#C0wm}6M8%Bl zNc_TSR*hB8r>XJ=Bg7g6*3)#*cl*q}^xz=w+Pfokg(ps&SO=5SKI8M9UO1NA1&yeG zw+s?Fwup%QFi}B~7#|(Ia`9+l@&8^OK<26#`3enw>`AA!*;DKs9PLmh++43CrbytW z;LKr2oZz~QrGO1`WIh3TjbV4|q*jxzVVg1)>Qy|wK>^N6RMLts5GM@W=L~RI)U{4~ zMcmfFH|pPL7+d?w*mUeDF)(n5JCDHL+*p1jQ5Tx#G{J5&8}U@^xqQVcmec!^S~W<0 z^*1PJC`W`7&d?l(Y#+dQ)^ABC`Ti;~B9H}uv2j!bv~no+Jrn@2Ks`Zre|hmNIq}LB z8Sl3b;LLt*irn?+qG$+wVlht5+95`;80BVQWPF)#^5O2MG(wg#vi)Ihh+w6OnwpxC zQF~AjO@Wn7zj6pSD1YTkd^kSfuSi!+N{!p~80uQcN^b0{Q6ByZK&-cgQUzd~I9qtv zRfuVF*WWfTSX~b$YYux?>9#79vMs$+^9ec^fVc#r#=Ygi-R16fN(ml^AK>5qKybo4e3b1i1F%% zr%%nkC_x_ku%{A1<-4kyl69c&ngpoQrA!<$D@joG6I^^3|7^>y)OOE`&DvQ52uo9w zPF_~w%`A@QFc`0Lt{~2}*|7;?l!yx#GBPA>4t2bz;x3xo)ewgzCbd5D-Sdxs=|Eo1 z$GkVLA5iS6EQG}2N~?!-nu_dO=x+1X#Vo*l-lb$;N>X{2`{^n95k(#yF^B4GP+Z*G zYL49akhA8S&5Y*&*XJCVKlbkFJHzC)0I9ATIJJ9&O#P$$b9U^MZ|GN}qKv?!-KCFzoGPW%d1ENK zY&iQ_Fr_?F()vn<;rLCRp6vSu*gj3RU;UW9eIDuy)`m)v1^SK3%D)m1rMXUBfXvb~ zUDNuUM(Y0Rxvr#wVU_7_GeDr(Zy|0M)ARg>ydZLbz8i%^&UL6L-$IU->8bQ(lx$O; zV?1D!x9U7+{2#ec&&!n8f5}9fiGe00+PJz1?yW(T5^)K8m8s9baph=XLe07-&=!cw z4~1+qyVK?5w|Ano_h{#kI;k&br?n7)orm~Qa-S6ERBd$H*5BhA;~U;O0mFd_`)+*;D5s=WWkE%tvO_KfUofPWsDdEHX3i+~os&ADTsSD4S7 zNa*J%_#v`o3n12?vAbideU2%tZ+V^1b<-w3qu$l9`k6gqz7H2;Vwt`%C&e<7Ii|O| zRCJbc*;G~_^vhLGQ`Z8BWzHc|r%r3y!LG>ze3?!KwmJG-t~9$pol};*C82x9pmiU+ zcoZN6@LJ|D{&12Kv}-@<-;D8UyLNT+a_i?(lnTuPZ7o~_|4X?>jIh@-G7oS8dKA!$%pf7*=Q_+v+aFSJ_S@A4XSk00d| zG-j^({)mO1k@f8cetwUa5f8%(+;bGwlO{9W>O+?%MYl|!l0%`FD(<=5??z-4$h@$iUfK-0B}!HEMbwE3F^ zRfL>tHn14k_yQ-eOL_bFx1X+jVIyH+_oa8$<65)E3=mq^pDcQI{}cB2?o3o|G#)*b zp#D(yIkT^k<)=Cr)d+&7BFsej(!BpnSd!FW(pRdPaL1otuj;aEWoS;lgNp$0puhWH z;)9wD!rZYQxnD?|{bzrwXC>>@935s3?8mN*YK4`Z{htlEo)rl{REl9ejkOb`E!edG z-LcQ#zB)g7i^hd})T$je7q5p)MY1-CFe<;IMN_^LoCjnVAgJmU?U%{}k&PF>Tdbz> zRA1mc+JqZE7v^q3t!*9wX+Wx*>)R%KQ<^4DzC38E@r|$juP#+1mY)#1fMpwJNpnxz zV%wi>W(kTQEGICHBZbKCjhr>LL(r$0R(Mrtvv&3B2?m-QT5Ma%WU{hypT<9lAQ|Gw zg!EtG!oS3G{vRYXwaX_U2wdc+5z-?OKd9-rJUrgg*<7m2rh@1zAUd4A(?}!A`?>Hl zXH&qwO)JhPrB$`4R^c1$VE(~B{-!rR|)X`X2&{)%gvAsjr!Hn*r zo-2qF1lsmC4Yd>bKk(LdyLEKJ{A0S9Em%_M5&v=xY!wbua5cTn6=7CLW=dfs{_;OH zKT13QzdUIfF7W&$97=@Fj$lbq()`PkdNXHmz4b=zsjuLQB(DF#k#1wa4WLKyf&EM>s@I2rIxQ3wz(3+gl`} zsEdHQ-{cyqywzXcdn4}sYOtm_Po2mB8@R6&_xDvQ129yhn%Zhrdaj73jiLOyFNAmy z*p$HFfM_`5RJo<{zBNrHxu}E7M~Is*JSs}Js#yZdyAmwXD8LKma|gojdT#Cx{aDlSUe8}I;jh>0wF88a^+!_ zea3z?PhWcDGsh{ zxV;Q7$2ZPZGEK4C887nPTTP&^W+ZfLRKfhqSwu+^N5oOMZ9*DK#$kWT7y}Icjz9Om zd)6H1HoekE(eLd1@xwHG9-2V9d!E~8R79&Y=H>k$7&36kqTr0YxhHFkcEH96xct)> zkK8G1u{G-oxqj0X29-Y5DTLFw!t=EsEDCUr#hitM4uj$+{+pS>Et-PgT;gdW1rda= zaiF8C2ok;)u~+5n($Uw~2P^?teP=QZ@7W`QqSDgEh-7J3`Du$jq4(yqe~aZ)UR*16Tf*XY%2cmf-xTEiR*8P6K}Di*#TG_OON*v~lSq$m9sl;tFgf=y zL$VooG{QWO{w=EyeO_@!?=;WFbW&zUXo#tG#~39xh@#$mAd52ks;CZr0EkGpr2Ctm zT6?8417-Z24hm%@RJF}VuBkyWCy5+Htj=PZa>kV%ZoRA*VJVKOhrhZ2(K^2&_wwe| zn=}jlmq`SBQR&ee!|6Rex|c7v{^`@Q>A!ZhA^8aNTb4f{(qW9W78)Gp6)M1iBgJ=2 zy@pYm!R3?LW^QQMhNK9Wba>L7jV(F8vOaShxPQ{T2fbG+EmS>r#;zoNbv)}5ZPMwP zF`?uX#6UMepBOWEji_QzVx#C*ELj&nk%zVlN{XyUK#9aAtqPY-s}haZ$kWZc_SiCR z-n=>HB9@ozGb`uLo|1ZVES)g5lijXS6qn`75KZO7;RP z{AE&M8v9jzjJvOHTqrpKd1tFzy^lstp(rgeP`C>T3B=Llf62U@hg5xA_b=C5`<0}I zi4Ga3`>BUw1H*r1*5Tkyi8Jt$vQ1)K84)>|QoSYCi!cxS+TggaWU}i<$w!cuPyG0C zWphCn84`VhklUu6lo;l9>sB^bpXhWpp9&%T@bEt5haUzqxwF)nqiO7{zKsc;HLX+B z-a_aZQ%kErZXDKWTopLiY_G@7cz_Q%yGvZ$Nw0bj+a`h0_O4T+p)M75^AjvL<1+ZS zJC%}&gTK014;nAOfn^SdUI~3E)Jt6(X6hBneqLvd#5Nocbkq9ekNUN(ZlMbLksX4e zk;!)L;QKvC3KOLA%egSYjAtxg=pQ^NEV4x;@Yg1wEX4Q@kzvEBfCXh(4jD$b8!y?t zb_bvO53lxkgI07@{yd88MAb0hbVLa0v$aF3MuVayM8CLbbu&OAmo(oSfiqB1SpeLC zPanpK_EJ?&-`Ia>xaf8^2fQW{#J+ybt$L&wzyCa|%0CB4%f1BCSpm3J3I zT;+8r_nJ1`v>t)V`|5=!XhKA3MU6ghl{93|U-EdBXa4ngb$^+|UeqhR+-dEwDFQAS zS$S3hJaQ83pL12$NIHRRE0-_`R!v@@wsi3^Ms3;m`NFDAJ@b&Gd_e~J-Hae2X#N0ho3GPi@E;I@Cq2Z-vaxv3v*dMSgGQKCzz@(i@jajJIjHySSo zlY5N3FETv5z&Ml6w`f*rj22g)*6B|=8>1Tg{vysh)E9@S91Go~uOXYSQIC4=Kc!}B z21}Fu0SiL>0ZfVr92isxR#if@Lgb%DGO^M}Mi#18Fq{&Ccm&xiOA9)e3;7HoEDOa} z0dpEa)xTQ>!j*|^{z1Lqfu7oc(?^fW2Kw5ux@c<|M_!DK{P0?=gQI(pIOZ(S&W^)H z+oq$~A2ZM2o#B+Anpz%jHQF{Rjutr%|vd(W(J25!k;BZ?_vx3!N%(n1m8Jv0whlGI-%2y4_-fN z2Mj<}Sa^sf?*Resj;yR+2R8&tZ>hyOPK@&maiUyHVgLVh1qKBA|8?DW;8|m1w9!%P zcW%MQP=);uthS5zOQ_pkL2^wXwYZlb|01<13@GSQaD+ExU-3lMhLoNEt!e|vqoIIM z1K~2`;1pAAmRKWm^YSLk=<-m#x^*B%%QPI8c-PQ#6HTO|Wy_X*m!myIi+@_VW{s8t zp;TW8zzT${k%Z`IDBg(g%wP89#!6sNqkR=5d$yDn_?w*|xVxIrA#7N`zSV+$3)V&| zHVcDv$M-xa7tBB23|<4x>-6l~5DtvnYZ6wEENDJqkjwbA1JghYyx-E&GJivYygLPD z0FKsdwQ%83&hc@C-kR`POtRmiE&Lqw=#i$|Pka9#^*B%0rGtwqV&mH^BH{!H7pEkh zbavhx$QC5q5wVET{u{s$LaAnjX)X)FS^)rW=*Ro_j*E^o$J9y6oA5i}v-X;O@B_;M zDz<6J*XBiNH>;CQo&A=G&F;hN?Z%NQDJc+sK9rz5>ux_pX{L7taFNG1RHG3gJdh6%QppOqebput-LFc7HnkN?%)mA^yTw&BRW zgsfR+N)s|kd8tWCL$(SmdbjUC_~r*cI2>l4XYTvC&ilO1^SqSBO>oTu$lJio0IlXC`f~tG9)RVTafB5< zQwuu4!RND#pjL~qubz!74kyG1*f%|@$A|!N#Jy;ToeP&^S%|II@(EQM{NxLBqx$?M}f>8pK21%j+un1&;9s5hr(Ab()MT8QBu*@;Eg}dtNd_e7?!XIcZ@i-igxKgKc4zrA9 z5C?3iAv9Yrc(B()B5f{9fMo%=Ecz@f8LI%(Hfa+&um_cu8|f=D|CT23;m>ITU*!li z*i<-;Qu>Cd#q1*ZUg2H=EsGAYvG6&}I_l5AczuP&K~v>@LOmkvla_2S06AWm8@g(@ z=6&F}q)ZqOJul@c2DqP+>lgbfYEqB`t{W`<8MEz({d#1w8M+f?N(~VlB9UWiyqA@0 zi1mUNE=YFs1&hMQOp?`%d?=&uISx91NVP%<7yTD#PwgBs#-Z9vmjZRk$mvWQWHV8} zpn%=Ke-?||`}82ROn#e*=%F)jVjF`p#sq&!5H7HOeHvBiaw?Hp`C8Lq@{yz~gotLp z=WT35B^ZzNiyTym73D-l0#cU3CNEX#YfxHoSIxc?JwqthI97ko{oGfsSG;YJv79## z31TQr4C=uK3F#;%+#Pi0ZAbeh#mJOVLcDT~`&hrSw~ixgAzCfYi-=)T6sI+!K7`=v z2s7c0Hyq|VR#AX;4e^JCrSG+-yA&MX5FhP#G-c0ry5uqfyc*vqWU!8{IR{9gs9QR1 zhSh4VD`SE`wNVpz>>A=3m@>KKg3pK?@hA>(+uLVhL&cFt{^1;P0`MWxdo3Uk6OW?2 z<~7ilp`EalIbh#5?Lwn#raAJH6eOppEqGAK$ZMecY^D>m+X5disC7CEo)MeV`N=pe zqx=PyMFEx}-GlHFToa!VJoSGQS|h^|(MNx6Q<6H|X&EGGsy2lZPpe!WZS`9IOfsgT zt=^;-lfOxLN=S4=<-fS|4;$MORzj820;XSH?MNxUMM{X*k+ytZMNWXlpo@zV5nQrK zpZKC&s5shho|@py^VOIolPfVQEiRt9Dj#>Ny<{NVeD>U@M4p7Ttb5p~5wuB-x%&hH z9iB}(xLb)5g$9EC30lHEgB(TGM(dLb-6gB*aV$=AJ!EqG5U>wg9Cr2i?bU8+*;~I2OUnphHV?t{@pWQ8)RS#ziKisAQYc-xS9qF#0^77HeYu!I{k(UP7AH1v6xtm6vTwxXI^ zjXQ~`7t%mlX)td`UQZKxRtYQS?-0cFJrywlyQ!VWGlM%Jm+K5Xj3PdmFSrWVbq`4! zTMZAqpQ8&6ySjqH&VkM~W4np!R^Eo@5VWU6mhaL>Z&!oK(*z9>8Cms?uqNnFUJael zd^oniTmoq!Udp{wC@3gsX^u_jvHC*@tRr$!!rnv}{U3b&0ACYBqQ|WTPO^pnrfSaG>1P|!+=SW!m1%@~V>ZY_)glb3 zSPT2H1#?CwvNvWyaPplSi-Z!RkeIyqycttKXHh8MQJL;zpunJ+eieO|eq{3rO5MoIv%T}I~eJYVrCQ!cE#_y**|ki^Ml+7LEH zVaa#0T|kEkpjVI%&dI0~osGy^oLnc7>(!w3#k$$K2)((XmBKMl6hZlWoZ0)m9sWeR z`WQNsbDb}R4<8*dnp}4{rK+~}6lTk#v(TIs;f)ksSB(7#YhO=dS!AOz!^XJ_IazP}C#1X9rVZyT_97K|$&@;Pz?1P!0Jh7hs=R1PG?BOIcU}u8qKl9#Ax(pDO@Lp=k^kXaI8AiDvd-I#Q)pV`;#A07 zUm&x`x1ELRm#XsqDV~ekN)} zI=bWRfAg|7!er4#^<0%|Kx6|p{?h;HQ*yZlhAqHz8T_H_z3PJPtYJ$7_9l$fHuUNJ zLFMI??ro@_uA`^NuFIeUA)T#&`JI;jygLrUiEThl^=+^lW8l*m|Jn}8D8D1`N=1L= z1cWRjd3M(YXZ3cr9C@>lXz0~c$* zXl3b5*VaDE=buhqLNB~E-9=9FVxb_Nz81LAV9Aouz`$ItgC*78-X08xtZ;Dg?bh(Q z+=2p|!gR2S9zCH6n7HM`V%;!08W-1MwjkX$gl;o7M}riXfAe7L5Rb>hAPE1!6u!E; zI!oE#Faa^4I*}zzSJ$$W3-v-Pt3Q6+0b{PAT$j}OI;XWm^_LNLu&atJ^ArIk&{HhA)+uu$8Yp+cc;&ZVe9`Q)gy`x6w17L8zbUzzELveZPFM zdO?(jp=wP{O|p_6g({V(c)8btL9-_B`_<#%3YSz6?8u$iqUnjRKG}2OJ3m7%W1F>g zr5~B1rKJRy(&>5e_A}CMBxmA=(=oGZ9*ib8#2)EWsSUnYvko;iIWeM-XufON)n22Z zMeU4qyZGKRT3^G_l8Vg5%E!S@wzjdvXF1hDd=`r=R{J`9fk)gE6*pdXCc5_1PIas- zDl99jis2~uS2|4P;o9j6rLMQGU5@_v0{I&750sZ1`+BsbL1V4EpspRG*{CwZal{JVoM{jd%Y#+QW6&xo_ zgNlubxnzKq+2)y4HPG8T&(xGmBGqb&<)+`10TxtKQ^TML|FI`eQhdGzdU?&l7#OyU zy`7zikkB&52|G-37Ca%(3~zGlN|iEfdYy*{AH|0d+pkQdr9;(}7 zk>TOt;1&C80XafmxVFZYpOo+MFH#y&YKOe%l;7_Co>BCPPkP;Fo=Qs+*cXmt3lpiQ zT!~D9Ld84EzbRII_%L_5=;cZMoYq#4U2)aCN42%G?{9P5y*xd2zARPo*Dcjl>h9?9 z_LPlRJRnj@Ye|B-2)&Z??9Dd5g4uOlJ9}@RKd*b;?=6F|8q?nCaq=1(u^*|ry3Pep z7h^dSyRWJC&@LMD2;~tQAc-IMeL44;4puYTtG2XC)?t=V)q*wl3s?bx8lUS_6;&V z*}bRhVmy~qIWSPvzlUMwFS}vau3hJU&mv=O=vX%@D=RlW(w{*B(cyh%=+h^sP{~o1 zM8*FMIa-b?$ZJN>gwx&zlr=7V#Gm0Jm)6t6PO3VPvtg7bFBrbQI6E6bySAY(M@Wf^ zk3+)3c+a=9*=){wJA3=4qYL_a)JY`tWlMOx22ufT+&(D|PfrQSiuMpqk?-½=? oPQgHE_4|0PtWe+GfBqa1*5R}Vq*eRSC*fsdVYmE(*~b0<1$cQO=>Px# literal 55406 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 "Store as line_price (gross), tax_rate" } --> "Apply discount engine" +--> "Apply tax rounding" --> "Store as price (gross)" @enduml diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index 680d1bd5d2..b233077e85 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -829,6 +829,7 @@ class EventSettingsSerializer(SettingsSerializer): 'invoice_eu_currencies', 'invoice_logo_image', 'invoice_renderer_highlight_order_code', + 'tax_rounding', 'cancel_allow_user', 'cancel_allow_user_until', 'cancel_allow_user_unpaid_keep', diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index ab3ff47bf0..808768fc2b 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -52,9 +52,10 @@ from pretix.base.decimal import round_decimal from pretix.base.i18n import language from pretix.base.invoicing.transmission import get_transmission_types from pretix.base.models import ( - CachedFile, Checkin, Customer, Invoice, InvoiceAddress, InvoiceLine, Item, - ItemVariation, Order, OrderPosition, Question, QuestionAnswer, - ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule, Voucher, + CachedFile, Checkin, Customer, Device, Invoice, InvoiceAddress, + InvoiceLine, Item, ItemVariation, Order, OrderPosition, Question, + QuestionAnswer, ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule, + Voucher, ) from pretix.base.models.orders import ( BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund, @@ -64,10 +65,13 @@ from pretix.base.pdf import get_images, get_variables from pretix.base.services.cart import error_messages from pretix.base.services.locking import LOCK_TRUST_WINDOW, lock_objects from pretix.base.services.pricing import ( - apply_discounts, get_line_price, get_listed_price, is_included_for_free, + apply_discounts, apply_rounding, get_line_price, get_listed_price, + is_included_for_free, ) from pretix.base.services.quotas import QuotaAvailability -from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS +from pretix.base.settings import ( + COUNTRIES_WITH_STATE_IN_ADDRESS, ROUNDING_MODES, +) from pretix.base.signals import register_ticket_outputs from pretix.helpers.countries import CachedCountries from pretix.multidomain.urlreverse import build_absolute_uri @@ -848,14 +852,15 @@ class OrderSerializer(I18nAwareModelSerializer): list_serializer_class = OrderListSerializer fields = ( 'code', 'event', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date', - 'payment_provider', 'fees', 'total', 'comment', 'custom_followup_at', 'invoice_address', 'positions', 'downloads', - 'checkin_attention', 'checkin_text', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel', - 'url', 'customer', 'valid_if_pending', 'api_meta', 'cancellation_date', 'plugin_data', + 'payment_provider', 'fees', 'total', 'tax_rounding_mode', 'comment', 'custom_followup_at', 'invoice_address', + 'positions', 'downloads', 'checkin_attention', 'checkin_text', 'last_modified', 'payments', 'refunds', + 'require_approval', 'sales_channel', 'url', 'customer', 'valid_if_pending', 'api_meta', 'cancellation_date', + 'plugin_data', ) read_only_fields = ( 'code', 'status', 'testmode', 'secret', 'datetime', 'expires', 'payment_date', - 'payment_provider', 'fees', 'total', 'positions', 'downloads', 'customer', - 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel', 'cancellation_date' + 'payment_provider', 'fees', 'total', 'tax_rounding_mode', 'positions', 'downloads', 'customer', + 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel', 'cancellation_date', ) def __init__(self, *args, **kwargs): @@ -1174,6 +1179,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer): queryset=SalesChannel.objects.none(), required=False, ) + tax_rounding_mode = serializers.ChoiceField(choices=ROUNDING_MODES, allow_null=True, required=False,) locale = serializers.ChoiceField(choices=[], required=False, allow_null=True) def __init__(self, *args, **kwargs): @@ -1190,7 +1196,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer): fields = ('code', 'status', 'testmode', 'email', 'phone', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel', 'invoice_address', 'positions', 'checkin_attention', 'checkin_text', 'payment_info', 'payment_date', 'consume_carts', 'force', 'send_email', 'simulate', 'customer', 'custom_followup_at', - 'require_approval', 'valid_if_pending', 'expires', 'api_meta') + 'require_approval', 'valid_if_pending', 'expires', 'api_meta', 'tax_rounding_mode') def validate_payment_provider(self, pp): if pp is None: @@ -1716,7 +1722,31 @@ class OrderCreateSerializer(I18nAwareModelSerializer): else: f.save() - order.total += sum([f.value for f in fees]) + rounding_mode = validated_data.get("tax_rounding_mode") + if not rounding_mode: + if isinstance(self.context.get("auth"), Device): + # Safety fallback to avoid differences in tax reporting + brand = self.context.get("auth").software_brand or "" + if "pretixPOS" in brand or "pretixKIOSK" in brand: + rounding_mode = "line" + if not rounding_mode: + rounding_mode = self.context["event"].settings.tax_rounding + changed = apply_rounding( + rounding_mode, + self.context["event"].currency, + [*pos_map.values(), *fees] + ) + for line in changed: + if isinstance(line, OrderPosition): + line.save(update_fields=[ + "price", "price_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction" + ]) + elif isinstance(line, OrderFee): + line.save(update_fields=[ + "value", "value_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction" + ]) + + order.total = sum([c.price for c in pos_map.values()]) + sum([f.value for f in fees]) if simulate: order.fees = fees order.positions = pos_map.values() diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index f818192aec..51d463047a 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -344,6 +344,7 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet): def get_serializer_context(self): ctx = super().get_serializer_context() ctx['event'] = self.request.event + ctx['auth'] = self.request.auth ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false').lower() == 'true' return ctx diff --git a/src/pretix/base/migrations/0293_cartposition_price_includes_rounding_correction_and_more.py b/src/pretix/base/migrations/0293_cartposition_price_includes_rounding_correction_and_more.py new file mode 100644 index 0000000000..548e84225e --- /dev/null +++ b/src/pretix/base/migrations/0293_cartposition_price_includes_rounding_correction_and_more.py @@ -0,0 +1,81 @@ +# Generated by Django 4.2.17 on 2025-04-20 13:58 + +from decimal import Decimal + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("pretixbase", "0292_giftcard_customer"), + ] + + operations = [ + migrations.AddField( + model_name="cartposition", + name="price_includes_rounding_correction", + field=models.DecimalField( + decimal_places=2, default=Decimal("0.00"), max_digits=13 + ), + ), + migrations.AddField( + model_name="cartposition", + name="tax_code", + field=models.CharField(max_length=190, null=True), + ), + migrations.AddField( + model_name="cartposition", + name="tax_value_includes_rounding_correction", + field=models.DecimalField( + decimal_places=2, default=Decimal("0.00"), max_digits=13 + ), + ), + migrations.AddField( + model_name="orderfee", + name="tax_value_includes_rounding_correction", + field=models.DecimalField( + decimal_places=2, default=Decimal("0.00"), max_digits=13 + ), + ), + migrations.AddField( + model_name="orderfee", + name="value_includes_rounding_correction", + field=models.DecimalField( + decimal_places=2, default=Decimal("0.00"), max_digits=13 + ), + ), + migrations.AddField( + model_name="orderposition", + name="price_includes_rounding_correction", + field=models.DecimalField( + decimal_places=2, default=Decimal("0.00"), max_digits=13 + ), + ), + migrations.AddField( + model_name="orderposition", + name="tax_value_includes_rounding_correction", + field=models.DecimalField( + decimal_places=2, default=Decimal("0.00"), max_digits=13 + ), + ), + migrations.AddField( + model_name="transaction", + name="price_includes_rounding_correction", + field=models.DecimalField( + decimal_places=2, default=Decimal("0.00"), max_digits=13 + ), + ), + migrations.AddField( + model_name="transaction", + name="tax_value_includes_rounding_correction", + field=models.DecimalField( + decimal_places=2, default=Decimal("0.00"), max_digits=13 + ), + ), + migrations.AddField( + model_name="order", + name="tax_rounding_mode", + field=models.CharField(default="line", max_length=100), + ), + ] diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index adcb69bcce..f8201f071a 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -81,7 +81,7 @@ from pretix.base.email import get_email_context from pretix.base.i18n import language from pretix.base.models import Customer, User from pretix.base.reldate import RelativeDateWrapper -from pretix.base.settings import PERSON_NAME_SCHEMES +from pretix.base.settings import PERSON_NAME_SCHEMES, ROUNDING_MODES from pretix.base.signals import allow_ticket_download, order_gracefully_delete from pretix.base.timemachine import time_machine_now @@ -324,6 +324,11 @@ class Order(LockModel, LoggedModel): # Invoice needs to be re-issued when the order is paid again default=False, ) + tax_rounding_mode = models.CharField( + max_length=100, + choices=ROUNDING_MODES, + default="line", + ) objects = ScopedManager(OrderQuerySet.as_manager().__class__, organizer='event__organizer') @@ -1259,7 +1264,8 @@ class Order(LockModel, LoggedModel): keys = set(target_transaction_count.keys()) | set(current_transaction_count.keys()) create = [] for k in keys: - positionid, itemid, variationid, subeventid, price, taxrate, taxruleid, taxvalue, feetype, internaltype, taxcode = k + (positionid, itemid, variationid, subeventid, price, price_includes_rounding_correction, taxrate, + taxruleid, taxvalue, taxvalue_includes_rounding_correction, feetype, internaltype, taxcode) = k d = target_transaction_count[k] - current_transaction_count[k] if d: create.append(Transaction( @@ -1272,9 +1278,11 @@ class Order(LockModel, LoggedModel): variation_id=variationid, subevent_id=subeventid, price=price, + price_includes_rounding_correction=price_includes_rounding_correction, tax_rate=taxrate, tax_rule_id=taxruleid, tax_value=taxvalue, + tax_value_includes_rounding_correction=taxvalue_includes_rounding_correction, tax_code=taxcode, fee_type=feetype, internal_type=internaltype, @@ -1449,7 +1457,22 @@ class QuestionAnswer(models.Model): super().delete(**kwargs) -class AbstractPosition(models.Model): +class RoundingCorrectionMixin: + + @property + def gross_price_before_rounding(self): + return self.price - self.price_includes_rounding_correction + + @property + def tax_value_before_rounding(self): + return self.tax_value - self.tax_value_includes_rounding_correction + + @property + def net_price_before_rounding(self): + return self.gross_price_before_rounding - self.tax_value_before_rounding + + +class AbstractPosition(RoundingCorrectionMixin, models.Model): """ A position can either be one line of an order or an item placed in a cart. @@ -1499,6 +1522,9 @@ class AbstractPosition(models.Model): decimal_places=2, max_digits=13, verbose_name=_("Price") ) + price_includes_rounding_correction = models.DecimalField( + max_digits=13, decimal_places=2, default=Decimal("0.00") + ) attendee_name_cached = models.CharField( max_length=255, verbose_name=_("Attendee name"), @@ -2272,7 +2298,7 @@ class ActivePositionManager(ScopedManager(organizer='order__event__organizer')._ return super().get_queryset().filter(canceled=False) -class OrderFee(models.Model): +class OrderFee(RoundingCorrectionMixin, models.Model): """ An OrderFee object represents a fee that is added to the order total independently of the actual positions. This might for example be a payment or a shipping fee. @@ -2322,6 +2348,9 @@ class OrderFee(models.Model): decimal_places=2, max_digits=13, verbose_name=_("Value") ) + value_includes_rounding_correction = models.DecimalField( + max_digits=13, decimal_places=2, default=Decimal("0.00") + ) order = models.ForeignKey( Order, verbose_name=_("Order"), @@ -2350,6 +2379,9 @@ class OrderFee(models.Model): max_digits=13, decimal_places=2, verbose_name=_('Tax value') ) + tax_value_includes_rounding_correction = models.DecimalField( + max_digits=13, decimal_places=2, default=Decimal("0.00") + ) canceled = models.BooleanField(default=False) all = ScopedManager(organizer='order__event__organizer') @@ -2398,17 +2430,23 @@ class OrderFee(models.Model): self.fee_type, self.value ) - def _calculate_tax(self, tax_rule=None, invoice_address=None): + def _calculate_tax(self, tax_rule=None, invoice_address=None, event=None): if tax_rule: self.tax_rule = tax_rule - try: - ia = invoice_address or self.order.invoice_address - except InvoiceAddress.DoesNotExist: + if invoice_address: + ia = invoice_address + elif hasattr(self, "order"): + try: + ia = self.order.invoice_address + except InvoiceAddress.DoesNotExist: + ia = None + else: ia = None - if not self.tax_rule and self.fee_type == "payment" and self.order.event.settings.tax_rule_payment == "default": - self.tax_rule = self.order.event.cached_default_tax_rule + event = event or self.order.event + if not self.tax_rule and self.fee_type == "payment" and event.settings.tax_rule_payment == "default": + self.tax_rule = event.cached_default_tax_rule if self.tax_rule: tax = self.tax_rule.tax(self.value, base_price_is='gross', invoice_address=ia, force_fixed_gross_price=True) @@ -2443,6 +2481,24 @@ class OrderFee(models.Model): self.order.touch() super().delete(**kwargs) + # For historical reasons, OrderFee has "value", but OrderPosition has "price". These properties + # help using them the same way. + @property + def price(self): + return self.value + + @price.setter + def price(self, value): + self.value = value + + @property + def price_includes_rounding_correction(self): + return self.value_includes_rounding_correction + + @price_includes_rounding_correction.setter + def price_includes_rounding_correction(self, value): + self.value_includes_rounding_correction = value + class OrderPosition(AbstractPosition): """ @@ -2522,6 +2578,9 @@ class OrderPosition(AbstractPosition): max_digits=13, decimal_places=2, verbose_name=_('Tax value') ) + tax_value_includes_rounding_correction = models.DecimalField( + max_digits=13, decimal_places=2, default=Decimal("0.00"), + ) secret = models.CharField(max_length=255, null=False, blank=False, db_index=True) web_secret = models.CharField(max_length=32, default=generate_secret, db_index=True) @@ -2694,7 +2753,14 @@ class OrderPosition(AbstractPosition): setattr(op, f.name, cp_mapping[cartpos.addon_to_id]) else: setattr(op, f.name, getattr(cartpos, f.name)) - op._calculate_tax() + + op.tax_value = cartpos.tax_value + op.tax_value_includes_rounding_correction = cartpos.tax_value_includes_rounding_correction + op.tax_rate = cartpos.tax_rate + op.tax_code = cartpos.tax_code + op.tax_rule = cartpos.item.tax_rule + # todo: is removing this safe? op._calculate_tax() + if cartpos.voucher: op.voucher_budget_use = cartpos.listed_price - cartpos.price_after_voucher @@ -3027,6 +3093,9 @@ class Transaction(models.Model): decimal_places=2, max_digits=13, verbose_name=_("Price") ) + price_includes_rounding_correction = models.DecimalField( + max_digits=13, decimal_places=2, default=Decimal("0.00") + ) tax_rate = models.DecimalField( max_digits=7, decimal_places=2, verbose_name=_('Tax rate') @@ -3044,6 +3113,9 @@ class Transaction(models.Model): max_digits=13, decimal_places=2, verbose_name=_('Tax value') ) + tax_value_includes_rounding_correction = models.DecimalField( + max_digits=13, decimal_places=2, default=Decimal("0.00") + ) fee_type = models.CharField( max_length=100, choices=OrderFee.FEE_TYPES, null=True, blank=True ) @@ -3073,14 +3145,19 @@ class Transaction(models.Model): @staticmethod def key(obj): if isinstance(obj, Transaction): - return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, obj.tax_rate, - obj.tax_rule_id, obj.tax_value, obj.fee_type, obj.internal_type, obj.tax_code) + return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, + obj.price_includes_rounding_correction, obj.tax_rate, obj.tax_rule_id, + obj.tax_value, obj.tax_value_includes_rounding_correction, obj.fee_type, + obj.internal_type, obj.tax_code) elif isinstance(obj, OrderPosition): - return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, obj.tax_rate, - obj.tax_rule_id, obj.tax_value, None, None, obj.tax_code) + return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, + obj.price_includes_rounding_correction, obj.tax_rate, obj.tax_rule_id, + obj.tax_value, obj.tax_value_includes_rounding_correction, None, + None, obj.tax_code) elif isinstance(obj, OrderFee): - return (None, None, None, None, obj.value, obj.tax_rate, - obj.tax_rule_id, obj.tax_value, obj.fee_type, obj.internal_type, obj.tax_code) + return (None, None, None, None, obj.value, obj.value_includes_rounding_correction, + obj.tax_rate, obj.tax_rule_id, obj.tax_value, obj.tax_value_includes_rounding_correction, + obj.fee_type, obj.internal_type, obj.tax_code) raise ValueError('invalid state') # noqa @property @@ -3091,6 +3168,14 @@ class Transaction(models.Model): def full_tax_value(self): return self.tax_value * self.count + @property + def full_price_includes_rounding_correction(self): + return self.price_includes_rounding_correction * self.count + + @property + def full_tax_value_includes_rounding_correction(self): + return self.tax_value_includes_rounding_correction * self.count + class CartPosition(AbstractPosition): """ @@ -3131,6 +3216,13 @@ class CartPosition(AbstractPosition): max_digits=7, decimal_places=2, default=Decimal('0.00'), verbose_name=_('Tax rate') ) + tax_code = models.CharField( + max_length=190, + null=True, blank=True, + ) + tax_value_includes_rounding_correction = models.DecimalField( + max_digits=13, decimal_places=2, default=Decimal("0.00") + ) listed_price = models.DecimalField( decimal_places=2, max_digits=13, null=True, ) @@ -3171,9 +3263,15 @@ class CartPosition(AbstractPosition): @property def tax_value(self): - net = round_decimal(self.price - (self.price * (1 - 100 / (100 + self.tax_rate))), + price = self.gross_price_before_rounding + net = round_decimal(price - (price * (1 - 100 / (100 + self.tax_rate))), self.event.currency) - return self.price - net + return self.gross_price_before_rounding - net + self.tax_value_includes_rounding_correction + + @tax_value.setter + def tax_value(self, value): + # ignore, tax value is always computed on the fly + pass @cached_property def sort_key(self): diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index 72f127770d..61808a6fb5 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -72,7 +72,7 @@ from pretix.helpers.countries import CachedCountries from pretix.helpers.format import format_map from pretix.helpers.money import DecimalTextInput from pretix.multidomain.urlreverse import build_absolute_uri -from pretix.presale.views import get_cart, get_cart_total +from pretix.presale.views import get_cart from pretix.presale.views.cart import cart_session, get_or_create_cart_id logger = logging.getLogger(__name__) @@ -1149,12 +1149,16 @@ class FreeOrderProvider(BasePaymentProvider): from .services.cart import get_fees cart = get_cart(request) - total = get_cart_total(request) + try: - total += sum([f.value for f in get_fees(self.event, request, total, None, None, cart)]) + fees = get_fees(event=request.event, request=request, + invoice_address=None, + payments=None, positions=cart) except TaxRule.SaleNotAllowed: # ignore for now, will fail on order creation - pass + fees = [] + total = sum([c.price for c in cart]) + sum([f.value for f in fees]) + return total == 0 def order_change_allowed(self, order: Order) -> bool: diff --git a/src/pretix/base/services/cancelevent.py b/src/pretix/base/services/cancelevent.py index 89d0d82b6f..694ea35987 100644 --- a/src/pretix/base/services/cancelevent.py +++ b/src/pretix/base/services/cancelevent.py @@ -350,7 +350,7 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool, ocm.add_fee(f) if dry_run: - refund_total += max(payment_refund_sum - (o.total + ocm._totaldiff), Decimal("0.00")) + refund_total += max(payment_refund_sum - (o.total + ocm._totaldiff_guesstimate), Decimal("0.00")) else: ocm.commit() refund_amount = payment_refund_sum - o.total diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index efffda3744..66c7c47bd3 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -66,8 +66,8 @@ from pretix.base.reldate import RelativeDateWrapper from pretix.base.services.checkin import _save_answers from pretix.base.services.locking import LockTimeoutException, lock_objects from pretix.base.services.pricing import ( - apply_discounts, get_line_price, get_listed_price, get_price, - is_included_for_free, + apply_discounts, apply_rounding, get_line_price, get_listed_price, + get_price, is_included_for_free, ) from pretix.base.services.quotas import QuotaAvailability from pretix.base.services.tasks import ProfiledEventTask @@ -1430,11 +1430,12 @@ class CartManager: ) for cp, (new_price, discount) in zip(positions, discount_results): - if cp.price != new_price or cp.discount_id != (discount.pk if discount else None): - diff += new_price - cp.price + if cp.gross_price_before_rounding != new_price or cp.discount_id != (discount.pk if discount else None): + diff += new_price - cp.gross_price_before_rounding cp.price = new_price + cp.price_includes_rounding_correction = Decimal("0.00") cp.discount = discount - cp.save(update_fields=['price', 'discount']) + cp.save(update_fields=['price', 'price_includes_rounding_correction', 'discount']) return diff @@ -1493,30 +1494,53 @@ def add_payment_to_cart(request, provider, min_value: Decimal=None, max_value: D add_payment_to_cart_session(cs, provider, min_value, max_value, info_data) -def get_fees(event, request, total, invoice_address, payments, positions): +def get_fees(event, request, _total_ignored_=None, invoice_address=None, payments=None, positions=None): + """ + Return all fees that would be created for the current cart. Also implicitly applies rounding on the order + positions. A recommended usage pattern to compute the total looks like this:: + + cart = get_cart(request) + fees = get_fees( + event=request.event, + request=request, + invoice_address=cached_invoice_address(request), + payments=None, + positions=cart, + ) + total = sum([c.price for c in cart]) + sum([f.value for f in fees]) + """ if payments and not isinstance(payments, list): raise TypeError("payments must now be a list") + if positions is None: + raise TypeError("Must pass positions, parameter is only optional for backwards-compat reasons") fees = [] + total = sum([c.gross_price_before_rounding for c in positions]) for recv, resp in fee_calculation_for_cart.send(sender=event, request=request, invoice_address=invoice_address, - total=total, positions=positions, payment_requests=payments): + positions=positions, total=total, payment_requests=payments): if resp: fees += resp - total = total + sum(f.value for f in fees) + for fee in fees: + fee._calculate_tax(invoice_address=invoice_address, event=event) + if fee.tax_rule and not fee.tax_rule.pk: + fee.tax_rule = None # TODO: deprecate + + apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees]) + total = sum([c.price for c in positions]) + sum([f.value for f in fees]) if total != 0 and payments: - total_remaining = total + payments_assigned = Decimal("0.00") for p in payments: # This algorithm of treating min/max values and fees needs to stay in sync between the following # places in the code base: # - pretix.base.services.cart.get_fees # - pretix.base.services.orders._get_fees # - pretix.presale.views.CartMixin.current_selected_payments - if p.get('min_value') and total_remaining < Decimal(p['min_value']): + if p.get('min_value') and total - payments_assigned < Decimal(p['min_value']): continue - to_pay = total_remaining + to_pay = max(total - payments_assigned, Decimal("0.00")) if p.get('max_value') and to_pay > Decimal(p['max_value']): to_pay = min(to_pay, Decimal(p['max_value'])) @@ -1525,28 +1549,32 @@ def get_fees(event, request, total, invoice_address, payments, positions): continue payment_fee = pprov.calculate_fee(to_pay) - total_remaining += payment_fee - to_pay += payment_fee - - if p.get('max_value') and to_pay > Decimal(p['max_value']): - to_pay = min(to_pay, Decimal(p['max_value'])) - - total_remaining -= to_pay - if payment_fee: if event.settings.tax_rule_payment == "default": payment_fee_tax_rule = event.cached_default_tax_rule or TaxRule.zero() else: payment_fee_tax_rule = TaxRule.zero() payment_fee_tax = payment_fee_tax_rule.tax(payment_fee, base_price_is='gross', invoice_address=invoice_address) - fees.append(OrderFee( + pf = OrderFee( fee_type=OrderFee.FEE_TYPE_PAYMENT, value=payment_fee, tax_rate=payment_fee_tax.rate, tax_value=payment_fee_tax.tax, tax_code=payment_fee_tax.code, tax_rule=payment_fee_tax_rule - )) + ) + fees.append(pf) + + # Re-apply rounding as grand total has changed + apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees]) + total = sum([c.price for c in positions]) + sum([f.value for f in fees]) + + # Re-calculate to_pay as grand total has changed + to_pay = max(total - payments_assigned, Decimal("0.00")) + if p.get('max_value') and to_pay > Decimal(p['max_value']): + to_pay = min(to_pay, Decimal(p['max_value'])) + + payments_assigned += to_pay return fees diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index bfe817d0fc..b37e584736 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -95,7 +95,7 @@ from pretix.base.services.memberships import ( create_membership, validate_memberships_in_order, ) from pretix.base.services.pricing import ( - apply_discounts, get_listed_price, get_price, + apply_discounts, apply_rounding, get_listed_price, get_price, ) from pretix.base.services.quotas import QuotaAvailability from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask @@ -947,10 +947,11 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti ] ) for cp, (new_price, discount) in zip(sorted_positions, discount_results): - if cp.price != new_price or cp.discount_id != (discount.pk if discount else None): + if cp.gross_price_before_rounding != new_price or cp.discount_id != (discount.pk if discount else None): cp.price = new_price + cp.price_includes_rounding_correction = Decimal("0.00") cp.discount = discount - cp.save(update_fields=['price', 'discount']) + cp.save(update_fields=['price', 'price_includes_rounding_correction', 'discount']) # After applying discounts, add-on positions might still have a reference to the *old* version of the # parent position, which can screw up ordering later since the system sees inconsistent data. @@ -973,10 +974,11 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti raise OrderError(err) -def _get_fees(positions: List[CartPosition], payment_requests: List[dict], address: InvoiceAddress, - meta_info: dict, event: Event, require_approval=False): +def _apply_rounding_and_fees(positions: List[CartPosition], payment_requests: List[dict], address: InvoiceAddress, + meta_info: dict, event: Event, require_approval=False): fees = [] - total = sum([c.price for c in positions]) + # Pre-rounding, pre-fee total is used for fee calculation + total = sum([c.gross_price_before_rounding for c in positions]) gift_cards = [] # for backwards compatibility for p in payment_requests: @@ -987,40 +989,53 @@ def _get_fees(positions: List[CartPosition], payment_requests: List[dict], addre meta_info=meta_info, positions=positions, gift_cards=gift_cards): if resp: fees += resp - total += sum(f.value for f in fees) - total_remaining = total + for fee in fees: + fee._calculate_tax(invoice_address=address, event=event) + if fee.tax_rule and not fee.tax_rule.pk: + fee.tax_rule = None # TODO: deprecate + + # Apply rounding to get final total in case no payment fees will be added + apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees]) + total = sum([c.price for c in positions]) + sum([f.value for f in fees]) + + payments_assigned = Decimal("0.00") for p in payment_requests: # This algorithm of treating min/max values and fees needs to stay in sync between the following # places in the code base: # - pretix.base.services.cart.get_fees # - pretix.base.services.orders._get_fees # - pretix.presale.views.CartMixin.current_selected_payments - if p.get('min_value') and total_remaining < Decimal(p['min_value']): + if p.get('min_value') and total - payments_assigned < Decimal(p['min_value']): p['payment_amount'] = Decimal('0.00') continue - to_pay = total_remaining + to_pay = max(total - payments_assigned, Decimal("0.00")) if p.get('max_value') and to_pay > Decimal(p['max_value']): to_pay = min(to_pay, Decimal(p['max_value'])) payment_fee = p['pprov'].calculate_fee(to_pay) - total_remaining += payment_fee - to_pay += payment_fee - - if p.get('max_value') and to_pay > Decimal(p['max_value']): - to_pay = min(to_pay, Decimal(p['max_value'])) - - total_remaining -= to_pay - - p['payment_amount'] = to_pay if payment_fee: pf = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=payment_fee, internal_type=p['pprov'].identifier) + pf._calculate_tax(invoice_address=address, event=event) fees.append(pf) p['fee'] = pf - if total_remaining != Decimal('0.00') and not require_approval: + # Re-apply rounding as grand total has changed + apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees]) + total = sum([c.price for c in positions]) + sum([f.value for f in fees]) + + # Re-calculate to_pay as grand total has changed + to_pay = max(total - payments_assigned, Decimal("0.00")) + + if p.get('max_value') and to_pay > Decimal(p['max_value']): + to_pay = min(to_pay, Decimal(p['max_value'])) + + payments_assigned += to_pay + p['payment_amount'] = to_pay + + if total != payments_assigned and not require_approval: raise OrderError(_("The selected payment methods do not cover the total balance.")) return fees @@ -1029,7 +1044,7 @@ def _get_fees(positions: List[CartPosition], payment_requests: List[dict], addre def _create_order(event: Event, *, email: str, positions: List[CartPosition], now_dt: datetime, payment_requests: List[dict], sales_channel: SalesChannel, locale: str=None, address: InvoiceAddress=None, meta_info: dict=None, shown_total=None, - customer=None, valid_if_pending=False, api_meta: dict=None): + customer=None, valid_if_pending=False, api_meta: dict=None, tax_rounding_mode=None): payments = [] try: @@ -1038,10 +1053,13 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no raise OrderError(e.message) require_approval = any(p.requires_approval(invoice_address=address) for p in positions) + + # Final calculation of fees, also performs final rounding try: - fees = _get_fees(positions, payment_requests, address, meta_info, event, require_approval=require_approval) + fees = _apply_rounding_and_fees(positions, payment_requests, address, meta_info, event, require_approval=require_approval) except TaxRule.SaleNotAllowed: raise OrderError(error_messages['country_blocked']) + total = pending_sum = sum([c.price for c in positions]) + sum([c.value for c in fees]) order = Order( @@ -1059,6 +1077,7 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no sales_channel=sales_channel, customer=customer, valid_if_pending=valid_if_pending, + tax_rounding_mode=tax_rounding_mode or event.settings.tax_rounding, ) if customer: order.email_known_to_work = customer.is_verified @@ -1073,12 +1092,6 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no for fee in fees: fee.order = order - try: - fee._calculate_tax() - except TaxRule.SaleNotAllowed: - raise OrderError(error_messages['country_blocked']) - if fee.tax_rule and not fee.tax_rule.pk: - fee.tax_rule = None # TODO: deprecate fee.save() # Safety check: Is the amount we're now going to charge the same amount the user has been shown when they @@ -1167,7 +1180,7 @@ def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosi def _perform_order(event: Event, payment_requests: List[dict], position_ids: List[str], email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web', - shown_total=None, customer=None, api_meta: dict=None): + shown_total=None, customer=None, api_meta: dict=None, tax_rounding_mode=None): for p in payment_requests: p['pprov'] = event.get_payment_providers(cached=True)[p['provider']] if not p['pprov']: @@ -1273,6 +1286,7 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis customer=customer, valid_if_pending=valid_if_pending, api_meta=api_meta, + tax_rounding_mode=tax_rounding_mode, ) try: @@ -1664,7 +1678,7 @@ class OrderChangeManager: self.split_order = None self.reissue_invoice = reissue_invoice self._committed = False - self._totaldiff = 0 + self._totaldiff_guesstimate = 0 self._quotadiff = Counter() self._seatdiff = Counter() self._operations = [] @@ -1781,7 +1795,7 @@ class OrderChangeManager: if position.issued_gift_cards.exists(): raise OrderError(self.error_messages['gift_card_change']) - self._totaldiff += price.gross - position.price + self._totaldiff_guesstimate += price.gross - position.gross_price_before_rounding if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00') or position.price != Decimal('0.00'): self._invoice_dirty = True @@ -1826,29 +1840,29 @@ class OrderChangeManager: else: new_tax = tax_rule.tax(pos.price, base_price_is='gross', currency=self.event.currency, override_tax_rate=new_rate, override_tax_code=new_code) - self._totaldiff += new_tax.gross - pos.price + self._totaldiff_guesstimate += new_tax.gross - pos.price self._operations.append(self.PriceOperation(pos, new_tax, new_tax.gross - pos.price)) self._invoice_dirty = True def cancel_fee(self, fee: OrderFee): - self._totaldiff -= fee.value + self._totaldiff_guesstimate -= fee.value self._operations.append(self.CancelFeeOperation(fee, -fee.value)) self._invoice_dirty = True def add_fee(self, fee: OrderFee): - self._totaldiff += fee.value + self._totaldiff_guesstimate += fee.value self._invoice_dirty = True self._operations.append(self.AddFeeOperation(fee, fee.value)) def change_fee(self, fee: OrderFee, value: Decimal): value = (fee.tax_rule or TaxRule.zero()).tax(value, base_price_is='gross', invoice_address=self._invoice_address, force_fixed_gross_price=True) - self._totaldiff += value.gross - fee.value + self._totaldiff_guesstimate += value.gross - fee.value self._invoice_dirty = True self._operations.append(self.FeeValueOperation(fee, value, value.gross - fee.value)) def cancel(self, position: OrderPosition): - self._totaldiff -= position.price + self._totaldiff_guesstimate -= position.price self._quotadiff.subtract(position.quotas) self._operations.append(self.CancelOperation(position, -position.price)) if position.seat: @@ -1914,7 +1928,7 @@ class OrderChangeManager: if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00'): self._invoice_dirty = True - self._totaldiff += price.gross + self._totaldiff_guesstimate += price.gross self._quotadiff.update(new_quotas) if seat: self._seatdiff.update([seat]) @@ -2210,8 +2224,8 @@ class OrderChangeManager: if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < diff): raise OrderError(self.error_messages['quota'].format(name=quota.name)) - def _check_paid_price_change(self): - if self.order.status == Order.STATUS_PAID and self._totaldiff > 0: + def _check_paid_price_change(self, totaldiff): + if self.order.status == Order.STATUS_PAID and totaldiff > 0: if self.order.pending_sum > Decimal('0.00'): self.order.status = Order.STATUS_PENDING self.order.set_expires( @@ -2219,7 +2233,7 @@ class OrderChangeManager: self.order.event.subevents.filter(id__in=self.order.positions.values_list('subevent_id', flat=True)) ) self.order.save() - elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and self._totaldiff < 0: + elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and totaldiff < 0: if self.order.pending_sum <= Decimal('0.00') and not self.order.require_approval: self.order.status = Order.STATUS_PAID self.order.save() @@ -2246,7 +2260,7 @@ class OrderChangeManager: user=self.user, auth=self.auth ) - elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and self._totaldiff > 0: + elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and totaldiff > 0: if self.open_payment: try: self.open_payment.payment_provider.cancel_payment(self.open_payment) @@ -2266,11 +2280,11 @@ class OrderChangeManager: auth=self.auth, ) - def _check_paid_to_free(self): - if self.event.currency == 'XXX' and self.order.total + self._totaldiff > Decimal("0.00"): + def _check_paid_to_free(self, totaldiff): + if self.event.currency == 'XXX' and self.order.total + totaldiff > Decimal("0.00"): raise OrderError(error_messages['currency_XXX']) - if self.order.total == 0 and (self._totaldiff < 0 or (self.split_order and self.split_order.total > 0)) and not self.order.require_approval: + if self.order.total == 0 and (totaldiff < 0 or (self.split_order and self.split_order.total > 0)) and not self.order.require_approval: if not self.order.fees.exists() and not self.order.positions.exists(): # The order is completely empty now, so we cancel it. self.order.status = Order.STATUS_CANCELED @@ -2278,7 +2292,7 @@ class OrderChangeManager: order_canceled.send(self.order.event, order=self.order) elif self.order.status != Order.STATUS_CANCELED: # if the order becomes free, mark it paid using the 'free' provider - # this could happen if positions have been made cheaper or removed (_totaldiff < 0) + # this could happen if positions have been made cheaper or removed (totaldiff < 0) # or positions got split off to a new order (split_order with positive total) p = self.order.payments.create( state=OrderPayment.PAYMENT_STATE_CREATED, @@ -2407,10 +2421,15 @@ class OrderChangeManager: 'new_price': op.price.gross }) position.price = op.price.gross + position.price_includes_rounding_correction = Decimal("0.00") position.tax_rate = op.price.rate position.tax_value = op.price.tax + position.tax_value_includes_rounding_correction = Decimal("0.00") position.tax_code = op.price.code - position.save(update_fields=['price', 'tax_rate', 'tax_value', 'tax_code']) + position.save(update_fields=[ + 'price', 'price_includes_rounding_correction', 'tax_rate', 'tax_value', + 'tax_value_includes_rounding_correction', 'tax_code' + ]) elif isinstance(op, self.TaxRuleOperation): if isinstance(op.position, OrderPosition): position = position_cache.setdefault(op.position.pk, op.position) @@ -2677,14 +2696,18 @@ class OrderChangeManager: except InvoiceAddress.DoesNotExist: pass - split_order.total = sum([p.price for p in split_positions if not p.canceled]) - + fees = [] for fee in self.order.fees.exclude(fee_type=OrderFee.FEE_TYPE_PAYMENT): new_fee = modelcopy(fee) new_fee.pk = None new_fee.order = split_order - split_order.total += new_fee.value new_fee.save() + fees.append(new_fee) + + changed_by_rounding = set(apply_rounding( + self.order.tax_rounding_mode, self.event.currency, [p for p in split_positions if not p.canceled] + fees + )) + split_order.total = sum([p.price for p in split_positions if not p.canceled]) if split_order.total != Decimal('0.00') and self.order.status != Order.STATUS_PAID: pp = self._get_payment_provider() @@ -2697,9 +2720,27 @@ class OrderChangeManager: fee._calculate_tax() if payment_fee != 0: fee.save() + fees.append(fee) elif fee.pk: + if fee in fees: + fees.remove(fee) fee.delete() - split_order.total += fee.value + + changed_by_rounding |= set(apply_rounding( + self.order.tax_rounding_mode, self.event.currency, [p for p in split_positions if not p.canceled] + fees + )) + split_order.total = sum([p.price for p in split_positions if not p.canceled]) + sum([f.value for f in fees]) + + for l in changed_by_rounding: + if isinstance(l, OrderPosition): + l.save(update_fields=[ + "price", "price_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction" + ]) + elif isinstance(l, OrderFee): + l.save(update_fields=[ + "value", "value_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction" + ]) + split_order.total = sum([p.price for p in split_positions if not p.canceled]) + sum([f.value for f in fees]) remaining_total = sum([p.price for p in self.order.positions.all()]) + sum([f.value for f in self.order.fees.all()]) offset_amount = min(max(0, self.completed_payment_sum - remaining_total), split_order.total) @@ -2759,9 +2800,12 @@ class OrderChangeManager: ).aggregate(s=Sum('amount'))['s'] or Decimal('0.00') return payment_sum - refund_sum - def _recalculate_total_and_payment_fee(self): - total = sum([p.price for p in self.order.positions.all()]) + sum([f.value for f in self.order.fees.all()]) + def _recalculate_rounding_total_and_payment_fee(self): + positions = list(self.order.positions.all()) + fees = list(self.order.fees.all()) + total = sum([p.price for p in positions]) + sum([f.value for f in fees]) payment_fee = Decimal('0.00') + fee_changed = False if self.open_payment: current_fee = Decimal('0.00') fee = None @@ -2789,14 +2833,32 @@ class OrderChangeManager: fee.value = payment_fee fee._calculate_tax() fee.save() + fee_changed = True if not self.open_payment.fee: self.open_payment.fee = fee self.open_payment.save(update_fields=['fee']) elif fee and not fee.canceled: fee.delete() + fee_changed = True - self.order.total = total + payment_fee + if fee_changed: + fees = list(self.order.fees.all()) + + changed = apply_rounding(self.order.tax_rounding_mode, self.order.event.currency, [*positions, *fees]) + for l in changed: + if isinstance(l, OrderPosition): + l.save(update_fields=[ + "price", "price_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction" + ]) + elif isinstance(l, OrderFee): + l.save(update_fields=[ + "value", "value_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction" + ]) + total = sum([p.price for p in positions]) + sum([f.value for f in fees]) + + self.order.total = total self.order.save() + return total def _check_order_size(self): if (len(self.order.positions.all()) + len([op for op in self._operations if isinstance(op, self.AddOperation)])) > settings.PRETIX_MAX_ORDER_SIZE: @@ -2806,23 +2868,6 @@ class OrderChangeManager: } ) - def _payment_fee_diff(self): - total = self.order.total + self._totaldiff - if self.open_payment: - current_fee = Decimal('0.00') - if self.open_payment and self.open_payment.fee: - current_fee = self.open_payment.fee.value - total -= current_fee - - # Do not change payment fees of paid orders - payment_fee = Decimal('0.00') - if self.order.pending_sum - current_fee != 0: - prov = self.open_payment.payment_provider - if prov: - payment_fee = prov.calculate_fee(total - self.completed_payment_sum) - - self._totaldiff += payment_fee - current_fee - def _reissue_invoice(self): i = self.order.invoices.filter(is_cancellation=False).last() if self.reissue_invoice and self._invoice_dirty: @@ -2953,6 +2998,13 @@ class OrderChangeManager: shared_lock_objects=[self.event] ) + def guess_totaldiff(self): + """ + Return the estimated difference of ``order.total`` based on the currently queued operations. This is only + a guess since it does not account for (a) tax rounding or (b) payment fee changes. + """ + return self._totaldiff_guesstimate + def commit(self, check_quotas=True): if self._committed: # an order change can only be committed once @@ -2968,8 +3020,6 @@ class OrderChangeManager: # so it's dangerous to keep the cache around. self.order._prefetched_objects_cache = {} - # finally, incorporate difference in payment fees - self._payment_fee_diff() self._check_order_size() with transaction.atomic(): @@ -2977,6 +3027,7 @@ class OrderChangeManager: if locked_instance.last_modified != self.order.last_modified: raise OrderError(error_messages['race_condition']) + original_total = self.order.total if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID): if check_quotas: self._check_quotas() @@ -2988,9 +3039,10 @@ class OrderChangeManager: self._perform_operations() except TaxRule.SaleNotAllowed: raise OrderError(self.error_messages['tax_rule_country_blocked']) - self._recalculate_total_and_payment_fee() - self._check_paid_price_change() - self._check_paid_to_free() + new_total = self._recalculate_rounding_total_and_payment_fee() + totaldiff = new_total - original_total + self._check_paid_price_change(totaldiff) + self._check_paid_to_free(totaldiff) if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID): self._reissue_invoice() self._clear_tickets_cache() @@ -3209,6 +3261,7 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay raise Exception('change_payment_provider should only be called in atomic transaction!') oldtotal = order.total + already_paid = order.payment_refund_sum e = OrderPayment.objects.filter(fee=OuterRef('pk'), state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED)) open_fees = list( @@ -3225,19 +3278,46 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay fee = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=Decimal('0.00'), order=order) old_fee = fee.value + positions = list(order.positions.all()) + fees = list(order.fees.all()) + rounding_changed = set(apply_rounding( + order.tax_rounding_mode, order.event.currency, [*positions, *[f for f in fees if f.pk != fee.pk]] + )) + total_without_fee = sum(c.price for c in positions) + sum(f.value for f in fees if f.pk != fee.pk) + pending_sum_without_fee = max(Decimal("0.00"), total_without_fee - already_paid) + new_fee = payment_provider.calculate_fee( - order.pending_sum - old_fee if amount is None else amount + pending_sum_without_fee if amount is None else amount ) if new_fee: fee.value = new_fee fee.internal_type = payment_provider.identifier fee._calculate_tax() + if fee in fees: + fees.remove(fee) + # "Update instance in the fees array + fees.append(fee) fee.save() else: + if fee in fees: + fees.remove(fee) if fee.pk: fee.delete() fee = None + rounding_changed |= set(apply_rounding( + order.tax_rounding_mode, order.event.currency, [*positions, *fees] + )) + for l in rounding_changed: + if isinstance(l, OrderPosition): + l.save(update_fields=[ + "price", "price_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction" + ]) + elif isinstance(l, OrderFee): + l.save(update_fields=[ + "value", "value_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction" + ]) + open_payment = None if new_payment: lp = order.payments.select_for_update(of=OF_SELF).exclude(pk=new_payment.pk).last() @@ -3264,7 +3344,7 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay }, ) - order.total = (order.positions.aggregate(sum=Sum('price'))['sum'] or 0) + (order.fees.aggregate(sum=Sum('value'))['sum'] or 0) + order.total = sum(c.price for c in positions) + sum(f.value for f in fees) order.save(update_fields=['total']) if not new_payment: diff --git a/src/pretix/base/services/pricing.py b/src/pretix/base/services/pricing.py index 5ac9dbea4c..6f03a91394 100644 --- a/src/pretix/base/services/pricing.py +++ b/src/pretix/base/services/pricing.py @@ -23,15 +23,17 @@ import re from collections import defaultdict from datetime import datetime from decimal import Decimal -from typing import List, Optional, Tuple, Union +from itertools import groupby +from typing import List, Literal, Optional, Tuple, Union from django import forms +from django.conf import settings from django.db.models import Q from pretix.base.decimal import round_decimal from pretix.base.models import ( - AbstractPosition, InvoiceAddress, Item, ItemAddOn, ItemVariation, - SalesChannel, Voucher, + AbstractPosition, CartPosition, InvoiceAddress, Item, ItemAddOn, + ItemVariation, OrderFee, OrderPosition, SalesChannel, Voucher, ) from pretix.base.models.discount import Discount, PositionInfo from pretix.base.models.event import Event, SubEvent @@ -136,7 +138,8 @@ def get_listed_price(item: Item, variation: ItemVariation = None, subevent: SubE def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, custom_price_input_is_net: bool, - tax_rule: TaxRule, invoice_address: InvoiceAddress, bundled_sum: Decimal, is_bundled=False) -> TaxedPrice: + tax_rule: TaxRule, invoice_address: InvoiceAddress, bundled_sum: Decimal, + is_bundled=False) -> TaxedPrice: if not tax_rule: tax_rule = TaxRule( name='', @@ -152,7 +155,8 @@ def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, cu override_tax_code=price.code, invoice_address=invoice_address, subtract_from_gross=bundled_sum) else: - price = tax_rule.tax(max(custom_price_input, price.gross), base_price_is='gross', override_tax_rate=price.rate, + price = tax_rule.tax(max(custom_price_input, price.gross), base_price_is='gross', + override_tax_rate=price.rate, override_tax_code=price.code, invoice_address=invoice_address, subtract_from_gross=bundled_sum) else: @@ -164,7 +168,7 @@ def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, cu def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel], positions: List[Tuple[int, Optional[int], Optional[datetime], Decimal, bool, bool, Decimal]], - collect_potential_discounts: Optional[defaultdict]=None) -> List[Tuple[Decimal, Optional[Discount]]]: + collect_potential_discounts: Optional[defaultdict] = None) -> List[Tuple[Decimal, Optional[Discount]]]: """ Applies any dynamic discounts to a cart @@ -203,3 +207,121 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel], new_prices.update(result) return [new_prices.get(idx, (p[3], None)) for idx, p in enumerate(positions)] + + +def apply_rounding(rounding_mode: Literal["line", "sum_by_net", "sum_by_net_keep_gross"], currency: str, + lines: List[Union[OrderPosition, CartPosition, OrderFee]]) -> list: + """ + Given a list of order positions / cart positions / order fees (may be mixed), applies the given rounding mode + and mutates the ``price``, ``price_includes_rounding_correction``, ``tax_value``, and + ``tax_value_includes_rounding_correction`` attributes. + + When rounding mode is set to ``"line"``, the tax will be computed and rounded individually for every line. + + When rounding mode is set to ``"sum_by_net_keep_gross"``, the tax values of the individual lines will be adjusted + such that the per-taxrate/taxcode subtotal is rounded correctly. The gross prices will stay constant. + + When rounding mode is set to ``"sum_by_net"``, the gross prices and tax values of the individual lines will be + adjusted such that the per-taxrate/taxcode subtotal is rounded correctly. The net prices will stay constant. + + :param rounding_mode: One of ``"line"``, ``"sum_by_net"``, or ``"sum_by_net_keep_gross"``. + :param currency: Currency that will be used to determine rounding precision + :param lines: List of order/cart contents + :return: Collection of ``lines`` members that have been changed and may need to be persisted to the database. + """ + + def _key(line): + return (line.tax_rate, line.tax_code) + + places = settings.CURRENCY_PLACES.get(currency, 2) + minimum_unit = Decimal('1') / 10 ** places + changed = [] + + if rounding_mode == "sum_by_net": + for (tax_rate, tax_code), lines in groupby(sorted(lines, key=_key), key=_key): + lines = list(sorted(lines, key=lambda l: -l.gross_price_before_rounding)) + + # Compute the net and gross total of the line-based computation method + net_total = sum(l.net_price_before_rounding for l in lines) + gross_total = sum(l.gross_price_before_rounding for l in lines) + + # Compute the gross total we need to achieve based on the net total + target_gross_total = round_decimal((net_total * (1 + tax_rate / 100)), currency) + + # Add/subtract the smallest possible from both gross prices and tax values (so net values stay the same) + # until the values align + diff = target_gross_total - gross_total + diff_sgn = -1 if diff < 0 else 1 + for l in lines: + if diff: + apply_diff = diff_sgn * minimum_unit + l.price = l.gross_price_before_rounding + apply_diff + l.price_includes_rounding_correction = apply_diff + l.tax_value = l.tax_value_before_rounding + apply_diff + l.tax_value_includes_rounding_correction = apply_diff + diff -= apply_diff + changed.append(l) + elif l.price_includes_rounding_correction or l.tax_value_includes_rounding_correction: + l.price = l.gross_price_before_rounding + l.price_includes_rounding_correction = Decimal("0.00") + l.tax_value = l.tax_value_before_rounding + l.tax_value_includes_rounding_correction = Decimal("0.00") + changed.append(l) + + elif rounding_mode == "sum_by_net_keep_gross": + for (tax_rate, tax_code), lines in groupby(sorted(lines, key=_key), key=_key): + lines = list(sorted(lines, key=lambda l: -l.gross_price_before_rounding)) + + # Compute the net and gross total of the line-based computation method + net_total = sum(l.net_price_before_rounding for l in lines) + gross_total = sum(l.gross_price_before_rounding for l in lines) + + # Compute the net total that would yield the correct gross total (if possible) + target_net_total = round_decimal(gross_total - (gross_total * (1 - 100 / (100 + tax_rate))), currency) + + # Compute the gross total that would be computed from that net total – this will be different than + # gross_total when there is no possible net value for the gross total + # e.g. 99.99 at 19% is impossible since 84.03 + 19% = 100.00 and 84.02 + 19% = 99.98 + target_gross_total = round_decimal((target_net_total * (1 + tax_rate / 100)), currency) + + diff_gross = target_gross_total - gross_total + diff_net = target_net_total - net_total + diff_gross_sgn = -1 if diff_gross < 0 else 1 + diff_net_sgn = -1 if diff_net < 0 else 1 + for l in lines: + if diff_gross: + apply_diff = diff_gross_sgn * minimum_unit + l.price = l.gross_price_before_rounding + apply_diff + l.price_includes_rounding_correction = apply_diff + l.tax_value = l.tax_value_before_rounding + apply_diff + l.tax_value_includes_rounding_correction = apply_diff + changed.append(l) + diff_gross -= apply_diff + elif diff_net: + apply_diff = diff_net_sgn * minimum_unit + l.price = l.gross_price_before_rounding + l.price_includes_rounding_correction = Decimal("0.00") + l.tax_value = l.tax_value_before_rounding - apply_diff + l.tax_value_includes_rounding_correction = -apply_diff + changed.append(l) + diff_net -= apply_diff + elif l.price_includes_rounding_correction or l.tax_value_includes_rounding_correction: + l.price = l.gross_price_before_rounding + l.price_includes_rounding_correction = Decimal("0.00") + l.tax_value = l.tax_value_before_rounding + l.tax_value_includes_rounding_correction = Decimal("0.00") + changed.append(l) + + elif rounding_mode == "line": + for l in lines: + if l.price_includes_rounding_correction or l.tax_value_includes_rounding_correction: + l.price = l.gross_price_before_rounding + l.price_includes_rounding_correction = Decimal("0.00") + l.tax_value = l.tax_value_before_rounding + l.tax_value_includes_rounding_correction = Decimal("0.00") + changed.append(l) + + else: + raise ValueError("Unknown rounding_mode") + + return changed diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 3bf4ead9e7..60e510b286 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -77,6 +77,13 @@ from pretix.control.forms import ( ) from pretix.helpers.countries import CachedCountries +ROUNDING_MODES = ( + ('line', _('Compute taxes for every line individually')), + ('sum_by_net', _('Compute taxes based on net total')), + ('sum_by_net_keep_gross', _('Compute taxes based on net total with stable gross prices')), + # We could also have sum_by_gross, but we're not aware of any use-cases for it +) + def country_choice_kwargs(): allcountries = list(CachedCountries()) @@ -324,7 +331,7 @@ DEFAULTS = { 'form_class': forms.BooleanField, 'serializer_class': serializers.BooleanField, 'form_kwargs': dict( - label=_("Show net prices instead of gross prices in the product list (not recommended!)"), + label=_("Show net prices instead of gross prices in the product list"), help_text=_("Independent of your choice, the cart will show gross prices as this is the price that needs to be " "paid."), @@ -465,6 +472,25 @@ DEFAULTS = { widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-order_phone_asked'}), ) }, + 'tax_rounding': { + 'default': 'line', + 'type': str, + 'form_class': forms.ChoiceField, + 'serializer_class': serializers.ChoiceField, + 'form_kwargs': dict( + label=_("Rounding of taxes"), + widget=forms.RadioSelect, + choices=ROUNDING_MODES, + help_text=_( + "Note that if you transfer your sales data from pretix to an external system for tax reporting, you " + "need to make sure to account for possible rounding differences if your external system rounds " + "differently than pretix." + ) + ), + 'serializer_kwargs': dict( + choices=ROUNDING_MODES, + ), + }, 'invoice_address_asked': { 'default': 'True', 'type': bool, diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 2c67d74b6c..f73d4bd4b1 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -68,7 +68,7 @@ from pretix.base.reldate import RelativeDateField, RelativeDateTimeField from pretix.base.services.placeholders import FormPlaceholderMixin from pretix.base.settings import ( COUNTRIES_WITH_STATE_IN_ADDRESS, DEFAULTS, PERSON_NAME_SCHEMES, - PERSON_NAME_TITLE_GROUPS, validate_event_settings, + PERSON_NAME_TITLE_GROUPS, ROUNDING_MODES, validate_event_settings, ) from pretix.base.validators import multimail_validate from pretix.control.forms import ( @@ -541,7 +541,6 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett 'show_date_to', 'show_times', 'show_items_outside_presale_period', - 'display_net_prices', 'hide_prices_from_attendees', 'presale_start_show_date', 'locales', @@ -799,6 +798,80 @@ class PaymentSettingsForm(EventSettingsValidationMixin, SettingsForm): return value +class DisplayNetPricesBooleanSelect(forms.RadioSelect): + def __init__(self, attrs=None): + choices = ( + ("false", format_html( + '{}
{}', + _("Prices including tax"), + _("Recommended if you sell tickets at least partly to consumers.") + )), + ("true", format_html( + '{}
{}', + _("Prices excluding tax"), + _("Recommended only if you sell tickets primarily to business customers.") + )), + ) + super().__init__(attrs, choices) + + def format_value(self, value): + try: + return { + True: "true", + False: "false", + "true": "true", + "false": "false", + }[value] + except KeyError: + return "unknown" + + def value_from_datadict(self, data, files, name): + value = data.get(name) + return { + True: True, + "True": True, + "False": False, + False: False, + "true": True, + "false": False, + }.get(value) + + +class TaxSettingsForm(EventSettingsValidationMixin, SettingsForm): + auto_fields = [ + 'display_net_prices', + 'tax_rounding', + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["display_net_prices"].label = _("Prices shown to customer") + self.fields["display_net_prices"].widget = DisplayNetPricesBooleanSelect() + help_text = { + "line": _( + "Recommended when e-invoicing is not required. Each product will be sold with the advertised " + "net and gross price. However, in orders of more than one product, the total tax amount " + "can differ from when it would be computed from the order total." + ), + "sum_by_net": _( + "Recommended for e-invoicing when you primarily sell to business customers and " + "show prices to customers excluding tax. " + "The gross price of some products may be changed to ensure correct rounding, while the net " + "prices will be kept as configured. This may cause the actual payment amount to differ." + ), + "sum_by_net_keep_gross": _( + "Recommended for e-invoicing when you primarily sell to consumers. " + "The gross or net price of some products may be changed automatically to ensure correct " + "rounding of the order total. The system attempts to keep gross prices as configured whenever " + "possible. Gross prices may still change if they are impossible to derive from a rounded net price." + ), + } + self.fields["tax_rounding"].choices = ( + (k, format_html('{}
{}', v, help_text.get(k, ""))) + for k, v in ROUNDING_MODES + ) + + class ProviderForm(SettingsForm): """ This is a SettingsForm, but if fields are set to required=True, validation @@ -1527,7 +1600,10 @@ class TaxRuleLineForm(I18nForm): rate = forms.DecimalField( label=_('Deviating tax rate'), max_digits=10, decimal_places=2, - required=False + required=False, + widget=forms.NumberInput(attrs={ + 'placeholder': _('Deviating tax rate'), + }) ) invoice_text = I18nFormField( label=_('Text on invoice'), diff --git a/src/pretix/control/navigation.py b/src/pretix/control/navigation.py index 99bdd86544..c3a0cbb8f9 100644 --- a/src/pretix/control/navigation.py +++ b/src/pretix/control/navigation.py @@ -86,7 +86,7 @@ def get_event_navigation(request: HttpRequest): 'active': url.url_name == 'event.settings.mail', }, { - 'label': _('Tax rules'), + 'label': _('Taxes'), 'url': reverse('control:event.settings.tax', kwargs={ 'event': request.event.slug, 'organizer': request.event.organizer.slug, diff --git a/src/pretix/control/templates/pretixcontrol/event/settings.html b/src/pretix/control/templates/pretixcontrol/event/settings.html index 3a90aef7ca..8d9a46f732 100644 --- a/src/pretix/control/templates/pretixcontrol/event/settings.html +++ b/src/pretix/control/templates/pretixcontrol/event/settings.html @@ -243,7 +243,6 @@ {% bootstrap_field sform.show_times layout="control" %}

{% trans "Product list" %}

{% bootstrap_field sform.show_quota_left layout="control" %} - {% bootstrap_field sform.display_net_prices layout="control" %} {% bootstrap_field sform.show_variations_expanded layout="control" %} {% bootstrap_field sform.hide_sold_out layout="control" %} diff --git a/src/pretix/control/templates/pretixcontrol/event/tax.html b/src/pretix/control/templates/pretixcontrol/event/tax.html new file mode 100644 index 0000000000..9a58c75405 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/event/tax.html @@ -0,0 +1,120 @@ +{% extends "pretixcontrol/event/settings_base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %}{% trans "Taxes" %}{% endblock %} +{% block inside %} +

{% trans "Taxes" %}

+ {% bootstrap_form_errors form layout="control" %} +
+ {% trans "Tax rules" %} +

+ {% blocktrans trimmed %} + Tax rules define different taxation scenarios that can then be assigned to the individual products. + Each tax rule contains a default tax rate and can optionally contain additional rules that depend + on the customer's country and type. + {% endblocktrans %} +

+ + {% if taxrules|length == 0 %} +
+

+ {% blocktrans trimmed %} + You haven't created any tax rules yet. + {% endblocktrans %} +

+ + {% trans "Create a new tax rule" %} +
+ {% else %} +
+ + + + + + + + + + + + {% for tr in taxrules %} + + + + + + + + {% endfor %} + + + + + + +
{% trans "Name" %}{% trans "Default" %}{% trans "Usage" %}{% trans "Rate" %}
+ + {{ tr.internal_name|default:tr.name }} + + + {% if tr.default %} + + + {% trans "Default" %} + + {% else %} +
+ {% csrf_token %} + +
+ {% endif %} +
+ {% blocktrans trimmed count count=tr.c_items %} + {{ count }} product + {% plural %} + {{ count }} products + {% endblocktrans %} + + {% if tr.price_includes_tax %} + {% blocktrans with rate=tr.rate %}incl. {{ rate }} %{% endblocktrans %} + {% else %} + {% blocktrans with rate=tr.rate %}excl. {{ rate }} %{% endblocktrans %} + {% endif %} + {% if tr.has_custom_rules %} +
{% trans "with custom rules" %} + {% elif tr.eu_reverse_charge %} +
{% trans "reverse charge enabled" %} + {% endif %} +
+ + +
+ {% trans "Create a new tax rule" %} + +
+
+ {% endif %} +
+
+ {% csrf_token %} +
+ {% trans "Tax settings" %} + {% bootstrap_field form.tax_rounding layout="control" %} + {% bootstrap_field form.display_net_prices layout="control" %} +
+
+ +
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/event/tax_index.html b/src/pretix/control/templates/pretixcontrol/event/tax_index.html deleted file mode 100644 index 19123adb1b..0000000000 --- a/src/pretix/control/templates/pretixcontrol/event/tax_index.html +++ /dev/null @@ -1,77 +0,0 @@ -{% extends "pretixcontrol/event/settings_base.html" %} -{% load i18n %} -{% block title %}{% trans "Tax rules" %}{% endblock %} -{% block inside %} -

{% trans "Tax rules" %}

- {% if taxrules|length == 0 %} -
-

- {% blocktrans trimmed %} - You haven't created any tax rules yet. - {% endblocktrans %} -

- - {% trans "Create a new tax rule" %} -
- {% else %} -

- {% trans "Create a new tax rule" %} - -

-
- - - - - - - - - - - {% for tr in taxrules %} - - - - - - - {% endfor %} - -
{% trans "Name" %}{% trans "Default" %}{% trans "Rate" %}
- - {{ tr.internal_name|default:tr.name }} - - - {% if tr.default %} - - - {% trans "Default" %} - - {% else %} -
- {% csrf_token %} - -
- {% endif %} -
- {% if tr.price_includes_tax %} - {% blocktrans with rate=tr.rate%}incl. {{ rate }} %{% endblocktrans %} - {% else %} - {% blocktrans with rate=tr.rate%}excl. {{ rate }} %{% endblocktrans %} - {% endif %} - {% if tr.eu_reverse_charge %} - ({% trans "reverse charge enabled" %}) - {% endif %} - - - -
-
- {% endif %} - {% include "pretixcontrol/pagination.html" %} -{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index 889dc6fdf3..952be0d551 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -681,6 +681,16 @@ {% endif %} {% endif %} + {% if django_settings.DEBUG %} +
+ + price = {{ line.price|floatformat:2 }}
+ rounding_correction = {{ line.price_includes_rounding_correction|floatformat:2 }}
+ tax_value = {{ line.tax_value|floatformat:2 }}
+ tax_value_rounding_correction = {{ line.tax_value_includes_rounding_correction|floatformat:2 }}
+ voucher_budget_use = {{ line.voucher_budget_use|floatformat:2 }} +
+ {% endif %}
@@ -721,6 +731,16 @@ {% endif %} {% endif %} + {% if django_settings.DEBUG %} +
+ + price = {{ fee.value|floatformat:2 }}
+ rounding_correction = {{ fee.value_includes_rounding_correction|floatformat:2 }}
+ tax_value = {{ fee.tax_value|floatformat:2 }}
+ tax_value_rounding_correction = {{ fee.tax_value_includes_rounding_correction|floatformat:2 }}
+ voucher_budget_use = {{ fee.voucher_budget_use|floatformat:2 }} +
+ {% endif %}
@@ -749,6 +769,10 @@
{{ items.total|money:event.currency }} +
+ + tax_rounding_mode = {{ order.tax_rounding_mode }} +
diff --git a/src/pretix/control/templates/pretixcontrol/order/transactions.html b/src/pretix/control/templates/pretixcontrol/order/transactions.html index 7d327de15e..90431ec73f 100644 --- a/src/pretix/control/templates/pretixcontrol/order/transactions.html +++ b/src/pretix/control/templates/pretixcontrol/order/transactions.html @@ -56,8 +56,26 @@ {{ t.get_tax_code_display }} {{ t.count }} × {{ t.price|money:request.event.currency }} - {{ t.full_tax_value|money:request.event.currency }} - {{ t.full_price|money:request.event.currency }} + + {{ t.full_tax_value|money:request.event.currency }} + {% if t.full_price_includes_rounding_correction %} +
+ {% blocktrans trimmed with amount=t.full_tax_value_includes_rounding_correction|money:request.event.currency %} + incl. {{ amount }} rounding correction + {% endblocktrans %} + + {% endif %} + + + {{ t.full_price|money:request.event.currency }} + {% if t.full_price_includes_rounding_correction %} +
+ {% blocktrans trimmed with amount=t.full_price_includes_rounding_correction|money:request.event.currency %} + incl. {{ amount }} rounding correction + {% endblocktrans %} + + {% endif %} + {% endfor %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index feb3b64014..04296c37ac 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -290,7 +290,7 @@ urlpatterns = [ re_path(r'^settings/invoice$', event.InvoiceSettings.as_view(), name='event.settings.invoice'), re_path(r'^settings/invoice/preview$', event.InvoicePreview.as_view(), name='event.settings.invoice.preview'), re_path(r'^settings/display', event.DisplaySettings.as_view(), name='event.settings.display'), - re_path(r'^settings/tax/$', event.TaxList.as_view(), name='event.settings.tax'), + re_path(r'^settings/tax/$', event.TaxSettings.as_view(), name='event.settings.tax'), re_path(r'^settings/tax/(?P\d+)/$', event.TaxUpdate.as_view(), name='event.settings.tax.edit'), re_path(r'^settings/tax/add$', event.TaxCreate.as_view(), name='event.settings.tax.add'), re_path(r'^settings/tax/(?P\d+)/delete$', event.TaxDelete.as_view(), name='event.settings.tax.delete'), diff --git a/src/pretix/control/views/event.py b/src/pretix/control/views/event.py index c72e81bb7f..3a521eb543 100644 --- a/src/pretix/control/views/event.py +++ b/src/pretix/control/views/event.py @@ -54,7 +54,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import PermissionDenied from django.core.files import File from django.db import transaction -from django.db.models import ProtectedError +from django.db.models import Count, ProtectedError from django.forms import inlineformset_factory from django.http import ( Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed, @@ -90,7 +90,7 @@ from pretix.control.forms.event import ( EventFooterLinkFormset, EventMetaValueForm, EventSettingsForm, EventUpdateForm, InvoiceSettingsForm, ItemMetaPropertyForm, MailSettingsForm, PaymentSettingsForm, ProviderForm, QuickSetupForm, - QuickSetupProductFormSet, TaxRuleForm, TaxRuleLineFormSet, + QuickSetupProductFormSet, TaxRuleForm, TaxRuleLineFormSet, TaxSettingsForm, TicketSettingsForm, WidgetCodeForm, ) from pretix.control.permissions import EventPermissionRequiredMixin @@ -648,6 +648,25 @@ class PaymentSettings(EventSettingsViewMixin, EventSettingsFormView): return context +class TaxSettings(EventSettingsViewMixin, EventSettingsFormView): + template_name = 'pretixcontrol/event/tax.html' + form_class = TaxSettingsForm + permission = 'can_change_event_settings' + + def get_success_url(self) -> str: + return reverse('control:event.settings.tax', kwargs={ + 'organizer': self.request.organizer.slug, + 'event': self.request.event.slug, + }) + + def get_context_data(self, *args, **kwargs) -> dict: + context = super().get_context_data(*args, **kwargs) + context['taxrules'] = self.request.event.tax_rules.annotate( + c_items=Count("item") + ).all() + return context + + class InvoiceSettings(EventSettingsViewMixin, EventSettingsFormView): model = Event form_class = InvoiceSettingsForm @@ -1263,16 +1282,6 @@ class EventComment(EventPermissionRequiredMixin, View): }) -class TaxList(EventSettingsViewMixin, EventPermissionRequiredMixin, PaginationMixin, ListView): - model = TaxRule - context_object_name = 'taxrules' - template_name = 'pretixcontrol/event/tax_index.html' - permission = 'can_change_event_settings' - - def get_queryset(self): - return self.request.event.tax_rules.all() - - class TaxCreate(EventSettingsViewMixin, EventPermissionRequiredMixin, CreateView): model = TaxRule form_class = TaxRuleForm diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index aec42dea81..1cb9edf66f 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -633,7 +633,9 @@ class OrderTransactions(OrderView): ctx['sums'] = self.order.transactions.aggregate( sum_count=Sum('count'), full_price=Sum(F('count') * F('price')), + full_price_includes_rounding_correction=Sum(F('count') * F('price_includes_rounding_correction')), full_tax_value=Sum(F('count') * F('tax_value')), + full_tax_value_includes_rounding_correction=Sum(F('count') * F('tax_value_includes_rounding_correction')), ) return ctx diff --git a/src/pretix/plugins/paypal2/views.py b/src/pretix/plugins/paypal2/views.py index 89cf81eaa5..336239a18e 100644 --- a/src/pretix/plugins/paypal2/views.py +++ b/src/pretix/plugins/paypal2/views.py @@ -73,7 +73,7 @@ from pretix.plugins.paypal2.payment import ( PaypalMethod, PaypalMethod as Paypal, PaypalWallet, ) from pretix.plugins.paypal.models import ReferencedPayPalObject -from pretix.presale.views import get_cart, get_cart_total +from pretix.presale.views import get_cart from pretix.presale.views.cart import cart_session logger = logging.getLogger('pretix.plugins.paypal2') @@ -147,7 +147,7 @@ class XHRView(View): cart_total = order.pending_sum + fee else: - cart_total = get_cart_total(request) + cart = get_cart(request) cart_payments = cart_session(request).get('payments', []) multi_use_cart_payments = [p for p in cart_payments if p.get('multi_use_supported')] simulated_payments = multi_use_cart_payments + [{ @@ -159,12 +159,13 @@ class XHRView(View): }] try: - for fee in get_fees(request.event, request, cart_total, None, simulated_payments, get_cart(request)): - cart_total += fee.value + fees = get_fees(event=request.event, request=request, invoice_address=None, + payments=simulated_payments, positions=cart) except TaxRule.SaleNotAllowed: # ignore for now, will fail on order creation - pass + fees = [] + cart_total = sum([c.price for c in cart]) + sum([f.value for f in fees]) total_remaining = cart_total for p in multi_use_cart_payments: if p.get('min_value') and total_remaining < Decimal(p['min_value']): diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 31a130b0f4..0629af00f0 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -91,9 +91,7 @@ from pretix.presale.signals import ( question_form_fields_overrides, ) from pretix.presale.utils import customer_login -from pretix.presale.views import ( - CartMixin, get_cart, get_cart_is_free, get_cart_total, -) +from pretix.presale.views import CartMixin, get_cart, get_cart_is_free from pretix.presale.views.cart import ( _items_from_post_data, cart_session, create_empty_cart_id, get_or_create_cart_id, @@ -1262,18 +1260,16 @@ class PaymentStep(CartMixin, TemplateFlowStep): @cached_property def _total_order_value(self): cart = get_cart(self.request) - total = get_cart_total(self.request) try: - total += sum([ - f.value for f in get_fees( - self.request.event, self.request, total, self.invoice_address, - [p for p in self.cart_session.get('payments', []) if p.get('multi_use_supported')], - cart, - ) - ]) + fees = get_fees( + event=self.request.event, request=self.request, invoice_address=self.invoice_address, + payments=[p for p in self.cart_session.get('payments', []) if p.get('multi_use_supported')], + positions=cart, + ) except TaxRule.SaleNotAllowed: # ignore for now, will fail on order creation - pass + fees = [] + total = sum([c.price for c in cart]) + sum([f.value for f in fees]) return Decimal(total) @cached_property @@ -1399,7 +1395,13 @@ class PaymentStep(CartMixin, TemplateFlowStep): def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) - ctx['current_payments'] = [p for p in self.current_selected_payments(self._total_order_value) if p.get('multi_use_supported')] + ctx['cart'] = self.get_cart() + ctx['current_payments'] = [ + p for p in self.current_selected_payments( + ctx['cart']['raw'], ctx['cart']['fees'], ctx['cart']['invoice_address'], + ) + if p.get('multi_use_supported') + ] ctx['remaining'] = self._total_order_value - sum(p['payment_amount'] for p in ctx['current_payments']) + sum(p['fee'] for p in ctx['current_payments']) ctx['providers'] = self.provider_forms ctx['show_fees'] = any(p['fee'] for p in self.provider_forms) @@ -1412,7 +1414,6 @@ class PaymentStep(CartMixin, TemplateFlowStep): ctx['selected'] = self.single_use_payment['provider'] else: ctx['selected'] = '' - ctx['cart'] = self.get_cart() return ctx def _is_allowed(self, prov, request): @@ -1425,14 +1426,20 @@ class PaymentStep(CartMixin, TemplateFlowStep): return False cart = get_cart(self.request) - total = get_cart_total(self.request) try: - total += sum([f.value for f in get_fees(self.request.event, self.request, total, self.invoice_address, - self.cart_session.get('payments', []), cart)]) + fees = get_fees( + event=self.request.event, + request=self.request, + invoice_address=self.invoice_address, + payments=self.cart_session.get('payments', []), + positions=cart + ) except TaxRule.SaleNotAllowed: # ignore for now, will fail on order creation - pass - selected = self.current_selected_payments(total, warn=warn, total_includes_payment_fees=True) + fees = [] + total = sum([c.price for c in cart]) + sum([f.value for f in fees]) + + selected = self.current_selected_payments(cart, fees, self.invoice_address, warn=warn) if sum(p['payment_amount'] for p in selected) != total: if warn: messages.error(request, _('Please select a payment method to proceed.')) @@ -1516,7 +1523,11 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep): ctx = super().get_context_data(**kwargs) ctx['cart'] = self.get_cart(answers=True) - selected_payments = self.current_selected_payments(ctx['cart']['total'], total_includes_payment_fees=True) + selected_payments = self.current_selected_payments( + ctx['cart']['raw'], + ctx['cart']['fees'], + ctx['cart']['invoice_address'], + ) ctx['payments'] = [] for p in selected_payments: if p['provider'] == 'free': diff --git a/src/pretix/presale/views/__init__.py b/src/pretix/presale/views/__init__.py index 3ed42cad70..f450e41438 100644 --- a/src/pretix/presale/views/__init__.py +++ b/src/pretix/presale/views/__init__.py @@ -32,6 +32,7 @@ # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations under the License. import copy +import warnings from collections import defaultdict from datetime import datetime, timedelta from decimal import Decimal @@ -50,10 +51,11 @@ from django_scopes import scopes_disabled from pretix.base.i18n import get_language_without_region from pretix.base.middleware import get_supported_language from pretix.base.models import ( - CartPosition, Customer, InvoiceAddress, ItemAddOn, Question, + CartPosition, Customer, InvoiceAddress, ItemAddOn, OrderFee, Question, QuestionAnswer, QuestionOption, TaxRule, ) from pretix.base.services.cart import get_fees +from pretix.base.services.pricing import apply_rounding from pretix.base.templatetags.money import money_filter from pretix.helpers.cookies import set_cookie_without_samesite from pretix.multidomain.urlreverse import eventreverse @@ -147,6 +149,30 @@ class CartMixin: 'question': value.label }) + if order: + fees = order.fees.all() + elif lcp: + try: + fees = get_fees( + event=self.request.event, + request=self.request, + invoice_address=self.invoice_address, + payments=payments if payments is not None else self.cart_session.get('payments', []), + positions=cartpos, + ) + except TaxRule.SaleNotAllowed: + # ignore for now, will fail on order creation + fees = [] + else: + fees = [] + + if not order: + apply_rounding(self.request.event.settings.tax_rounding, self.request.event.currency, [*lcp, *fees]) + + total = sum([c.price for c in lcp]) + sum([f.value for f in fees]) + net_total = sum(p.price - p.tax_value for p in lcp) + sum([f.net_value for f in fees]) + tax_total = sum(p.tax_value for p in lcp) + sum([f.tax_value for f in fees]) + # Group items of the same variation # We do this by list manipulations instead of a GROUP BY query, as # Django is unable to join related models in a .values() query @@ -177,7 +203,7 @@ class CartMixin: pos.subevent_id, pos.item_id, pos.variation_id, - pos.price, + pos.net_price if self.request.event.settings.display_net_prices else pos.price, (pos.voucher_id or 0), (pos.seat_id or 0), pos.valid_from, @@ -204,29 +230,6 @@ class CartMixin: group.additional_answers = pos_additional_fields.get(group.pk) positions.append(group) - total = sum(p.total for p in positions) - net_total = sum(p.net_total for p in positions) - tax_total = sum(p.total - p.net_total for p in positions) - - if order: - fees = order.fees.all() - elif positions: - try: - fees = get_fees( - self.request.event, self.request, total, self.invoice_address, - payments if payments is not None else self.cart_session.get('payments', []), - cartpos - ) - except TaxRule.SaleNotAllowed: - # ignore for now, will fail on order creation - fees = [] - else: - fees = [] - - total += sum([f.value for f in fees]) - net_total += sum([f.net_value for f in fees]) - tax_total += sum([f.tax_value for f in fees]) - try: first_expiry = min(p.expires for p in positions) if positions else now() max_expiry_extend = min((p.max_extend for p in positions if p.max_extend), default=None) @@ -255,20 +258,28 @@ class CartMixin: 'max_expiry_extend': max_expiry_extend, 'is_ordered': bool(order), 'itemcount': sum(c.count for c in positions if not c.addon_to), - 'current_selected_payments': [p for p in self.current_selected_payments(total) if p.get('multi_use_supported')] + 'current_selected_payments': [ + p for p in self.current_selected_payments(positions, fees, self.invoice_address) + if p.get('multi_use_supported') + ] } - def current_selected_payments(self, total, warn=False, total_includes_payment_fees=False): + def current_selected_payments(self, positions, fees, invoice_address, *, warn=False): raw_payments = copy.deepcopy(self.cart_session.get('payments', [])) + fees = [f for f in fees if f.fee_type != OrderFee.FEE_TYPE_PAYMENT] # we re-compute these here + + apply_rounding(self.request.event.settings.tax_rounding, self.request.event.currency, [*positions, *fees]) + total = sum([c.price for c in positions]) + sum([f.value for f in fees]) + payments = [] - total_remaining = total + payments_assigned = Decimal("0.00") for p in raw_payments: # This algorithm of treating min/max values and fees needs to stay in sync between the following # places in the code base: # - pretix.base.services.cart.get_fees # - pretix.base.services.orders._get_fees # - pretix.presale.views.CartMixin.current_selected_payments - if p.get('min_value') and total_remaining < Decimal(p['min_value']): + if p.get('min_value') and total - payments_assigned < Decimal(p['min_value']): if warn: messages.warning( self.request, @@ -279,7 +290,7 @@ class CartMixin: self._remove_payment(p['id']) continue - to_pay = total_remaining + to_pay = max(total - payments_assigned, Decimal("0.00")) if p.get('max_value') and to_pay > Decimal(p['max_value']): to_pay = min(to_pay, Decimal(p['max_value'])) @@ -288,12 +299,36 @@ class CartMixin: self._remove_payment(p['id']) continue - if not total_includes_payment_fees: - fee = pprov.calculate_fee(to_pay) - total_remaining += fee - to_pay += fee - else: - fee = Decimal('0.00') + payment_fee = pprov.calculate_fee(to_pay) + if payment_fee: + if self.request.event.settings.tax_rule_payment == "default": + payment_fee_tax_rule = self.request.event.cached_default_tax_rule or TaxRule.zero() + else: + payment_fee_tax_rule = TaxRule.zero() + try: + payment_fee_tax = payment_fee_tax_rule.tax(payment_fee, base_price_is='gross', invoice_address=invoice_address) + except TaxRule.SaleNotAllowed: + # Replicate behavior from elsewhere, will fail later at the order stage + payment_fee = Decimal("0.00") + payment_fee_tax = TaxRule.zero().tax(payment_fee) + pf = OrderFee( + fee_type=OrderFee.FEE_TYPE_PAYMENT, + value=payment_fee, + tax_rate=payment_fee_tax.rate, + tax_value=payment_fee_tax.tax, + tax_code=payment_fee_tax.code, + tax_rule=payment_fee_tax_rule + ) + fees.append(pf) + + # Re-apply rounding as grand total has changed + apply_rounding(self.request.event.settings.tax_rounding, self.request.event.currency, [*positions, *fees]) + total = sum([c.price for c in positions]) + sum([f.value for f in fees]) + + # Re-calculate to_pay as grand total has changed + to_pay = max(total - payments_assigned, Decimal("0.00")) + if p.get('max_value') and to_pay > Decimal(p['max_value']): + to_pay = min(to_pay, Decimal(p['max_value'])) if p.get('max_value') and to_pay > Decimal(p['max_value']): to_pay = min(to_pay, Decimal(p['max_value'])) @@ -301,8 +336,8 @@ class CartMixin: p['payment_amount'] = to_pay p['provider_name'] = pprov.public_name p['pprov'] = pprov - p['fee'] = fee - total_remaining -= to_pay + p['fee'] = payment_fee + payments_assigned += to_pay payments.append(p) return payments @@ -373,6 +408,21 @@ def get_cart(request): def get_cart_total(request): + """ + Use the following pattern instead:: + + cart = get_cart(request) + fees = get_fees( + event=request.event, + request=request, + invoice_address=cached_invoice_address(request), + payments=None, + positions=cart, + ) + total = sum([c.price for c in cart]) + sum([f.value for f in fees]) + """ + warnings.warn('get_cart_total is deprecated and will be removed in a future release', + DeprecationWarning) from pretix.presale.views.cart import get_or_create_cart_id if not hasattr(request, '_cart_total_cache'): @@ -409,13 +459,14 @@ def get_cart_is_free(request): cs = cart_session(request) pos = get_cart(request) ia = get_cart_invoice_address(request) - total = get_cart_total(request) try: - fees = get_fees(request.event, request, total, ia, cs.get('payments', []), pos) + fees = get_fees(event=request.event, request=request, invoice_address=ia, + payments=cs.get('payments', []), positions=pos) except TaxRule.SaleNotAllowed: # ignore for now, will fail on order creation fees = [] - request._cart_free_cache = total + sum(f.value for f in fees) == Decimal('0.00') + + request._cart_free_cache = sum(p.price for p in pos) + sum(f.value for f in fees) == Decimal('0.00') return request._cart_free_cache diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index 291e4a1291..dc8182dd6d 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -1552,6 +1552,7 @@ class OrderChangeMixin: def post(self, request, *args, **kwargs): was_paid = self.order.status == Order.STATUS_PAID + original_total = self.order.total ocm = OrderChangeManager( self.order, notify=True, @@ -1603,7 +1604,8 @@ class OrderChangeMixin: except OrderError as e: messages.error(self.request, str(e)) else: - if self.order.pending_sum < Decimal('0.00') and ocm._totaldiff < Decimal('0.00'): + totaldiff = self.order.total - original_total + if self.order.pending_sum < Decimal('0.00') and totaldiff < Decimal('0.00'): auto_refund = ( not self.request.event.settings.cancel_allow_user_paid_require_approval and self.request.event.settings.cancel_allow_user_paid_refund_as_giftcard != "manually" @@ -1631,7 +1633,7 @@ class OrderChangeMixin: messages.info(self.request, _('You did not make any changes.')) return redirect(self.get_self_url()) else: - new_pending_sum = self.order.pending_sum + ocm._totaldiff + new_pending_sum = self.order.pending_sum + ocm.guess_totaldiff() can_auto_refund = False if new_pending_sum < Decimal('0.00'): proposals = self.order.propose_auto_refunds(Decimal('-1.00') * new_pending_sum) @@ -1639,7 +1641,7 @@ class OrderChangeMixin: return render(request, self.confirm_template_name, { 'operations': ocm._operations, - 'totaldiff': ocm._totaldiff, + 'totaldiff': ocm.guess_totaldiff(), 'order': self.order, 'payment_refund_sum': self.order.payment_refund_sum, 'new_pending_sum': new_pending_sum, @@ -1651,16 +1653,17 @@ class OrderChangeMixin: def _validate_total_diff(self, ocm): pr = self.get_price_requirement() - if ocm._totaldiff < Decimal('0.00') and pr == 'gte': + totaldiff = ocm.guess_totaldiff() + if totaldiff < Decimal('0.00') and pr == 'gte': raise OrderError(_('You may not change your order in a way that reduces the total price.')) - if ocm._totaldiff <= Decimal('0.00') and pr == 'gt': + if totaldiff <= Decimal('0.00') and pr == 'gt': raise OrderError(_('You may only change your order in a way that increases the total price.')) - if ocm._totaldiff != Decimal('0.00') and pr == 'eq': + if totaldiff != Decimal('0.00') and pr == 'eq': raise OrderError(_('You may not change your order in a way that changes the total price.')) - if ocm._totaldiff < Decimal('0.00') and self.order.total + ocm._totaldiff < self.order.payment_refund_sum and pr == 'gte_paid': + if totaldiff < Decimal('0.00') and self.order.total + totaldiff < self.order.payment_refund_sum and pr == 'gte_paid': raise OrderError(_('You may not change your order in a way that would require a refund.')) - if ocm._totaldiff > Decimal('0.00') and self.order.status == Order.STATUS_PAID: + if totaldiff > Decimal('0.00') and self.order.status == Order.STATUS_PAID: self.order.set_expires( now(), self.order.event.subevents.filter(id__in=self.order.positions.values_list('subevent_id', flat=True)) @@ -1669,7 +1672,7 @@ class OrderChangeMixin: raise OrderError(_('You may not change your order in a way that increases the total price since ' 'payments are no longer being accepted for this event.')) - if ocm._totaldiff > Decimal('0.00') and self.order.status == Order.STATUS_PENDING: + if totaldiff > Decimal('0.00') and self.order.status == Order.STATUS_PENDING: for p in self.order.payments.filter(state=OrderPayment.PAYMENT_STATE_PENDING): if not p.payment_provider.abort_pending_allowed: raise OrderError(_('You may not change your order in a way that requires additional payment while ' diff --git a/src/tests/api/test_order_create.py b/src/tests/api/test_order_create.py index e954da0477..f3f1d7e3ef 100644 --- a/src/tests/api/test_order_create.py +++ b/src/tests/api/test_order_create.py @@ -420,6 +420,7 @@ def test_order_create_simulate(token_client, organizer, event, item, quota, ques } ], 'total': '21.75', + 'tax_rounding_mode': 'line', 'comment': '', 'api_meta': {}, "custom_followup_at": None, @@ -3259,3 +3260,79 @@ def test_order_create_auto_pricing_explicit_discount_not_allowed(token_client, o } ] } + + +@pytest.mark.django_db +def test_order_create_rounding_mode(token_client, organizer, event, item, quota, question, taxrule): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res["tax_rounding_mode"] = "sum_by_net" + res['fees'][0]['_split_taxes_like_products'] = True + res['fees'][0]['value'] = Decimal("100.00") + res['positions'] = [ + { + "item": item.pk, + "price": "100.00", + } + ] * 4 + + for simulate in (True, False): + res["simulate"] = simulate + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + assert resp.data["total"] == "499.98" + assert resp.data["positions"][0]["price"] == "99.99" + assert resp.data["positions"][-1]["price"] == "100.00" + + res["tax_rounding_mode"] = "sum_by_net_keep_gross" + for simulate in (True, False): + res["simulate"] = simulate + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + assert resp.data["total"] == "500.00" + assert resp.data["positions"][0]["tax_value"] == "15.96" + assert resp.data["positions"][-1]["tax_value"] == "15.97" + + +@pytest.mark.django_db +def test_order_create_rounding_default_pretixpos_fallback(device, device_client, organizer, event, item, quota, question, taxrule): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['fees'][0]['_split_taxes_like_products'] = True + res['fees'][0]['value'] = Decimal("100.00") + res['positions'] = [ + { + "item": item.pk, + "price": "100.00", + } + ] * 4 + + event.settings.tax_rounding = "sum_by_net" + + resp = device_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + assert resp.data["total"] == "499.98" + assert resp.data["positions"][0]["price"] == "99.99" + assert resp.data["positions"][-1]["price"] == "100.00" + + device.software_brand = "pretixPOS Android" + device.save() + resp = device_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + assert resp.data["total"] == "500.00" + assert resp.data["positions"][0]["price"] == "100.00" + assert resp.data["positions"][-1]["price"] == "100.00" diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index 08b7bd16bb..888b0aefef 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -306,6 +306,7 @@ TEST_ORDER_RES = { "url": "http://example.com/dummy/dummy/order/FOO/k24fiuwvu8kxz3y1/", "payment_provider": "banktransfer", "total": "23.00", + "tax_rounding_mode": "line", "comment": "", "api_meta": {}, "custom_followup_at": None, diff --git a/src/tests/base/test_orders.py b/src/tests/base/test_orders.py index 032c648c81..486fb24a70 100644 --- a/src/tests/base/test_orders.py +++ b/src/tests/base/test_orders.py @@ -1820,6 +1820,63 @@ class OrderChangeManagerTests(TestCase): 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 + @classscope(attr='o') + def test_change_price_with_rounding_change_impossible(self): + # Order starts with 2*100€ tickets, but rounding corrects it to 199€. Then, the user tries to force both prices + # to 100€. No luck. + self.order.status = Order.STATUS_PAID + self.order.tax_rounding_mode = "sum_by_net" + self.order.save() + self.op1.price = Decimal("100.00") + self.op1._calculate_tax(tax_rule=self.tr19) + self.op1.save() + self.op2.price = Decimal("100.00") + self.op2._calculate_tax(tax_rule=self.tr19) + self.op2.save() + self.order.refresh_from_db() + self.ocm.regenerate_secret(self.op1) + self.ocm.commit() # Force re-rounding + self.order.refresh_from_db() + self.ocm = OrderChangeManager(self.order, None) + assert self.order.total == Decimal("199.99") + + self.ocm.change_price(self.op1, Decimal('100.00')) + self.ocm.change_price(self.op2, Decimal('100.00')) + self.ocm.commit() + self.op1.refresh_from_db() + self.op2.refresh_from_db() + self.order.refresh_from_db() + assert self.order.total == Decimal("199.99") + assert self.op1.price == Decimal('99.99') + assert self.op2.price == Decimal('100.00') + + @classscope(attr='o') + def test_change_price_with_rounding_change_autocorrected(self): + self.order.status = Order.STATUS_PAID + self.order.tax_rounding_mode = "sum_by_net" + self.order.save() + self.op1.price = Decimal("0.00") + self.op1._calculate_tax(tax_rule=self.tr19) + self.op1.save() + self.op2.price = Decimal("100.00") + self.op2._calculate_tax(tax_rule=self.tr19) + self.op2.save() + self.order.refresh_from_db() + self.ocm.regenerate_secret(self.op1) + self.ocm.commit() # Force re-rounding + self.order.refresh_from_db() + self.ocm = OrderChangeManager(self.order, None) + assert self.order.total == Decimal("100.00") + + self.ocm.change_price(self.op1, Decimal('100.00')) + self.ocm.commit() + self.op1.refresh_from_db() + self.op2.refresh_from_db() + self.order.refresh_from_db() + assert self.order.total == Decimal("199.99") + assert self.op1.price == Decimal('99.99') + assert self.op2.price == Decimal('100.00') + @classscope(attr='o') def test_change_price_net_success(self): self.tr7.price_includes_tax = False @@ -2408,6 +2465,24 @@ class OrderChangeManagerTests(TestCase): assert nop.price == Decimal('12.00') assert nop.subevent == se1 + @classscope(attr='o') + def test_add_item_with_rounding(self): + self.order.tax_rounding_mode = "sum_by_net" + self.order.save() + self.ocm.add_position(self.ticket, None, None, None) + self.ocm.commit() + self.order.refresh_from_db() + assert self.order.positions.count() == 3 + op1, op2, op3 = self.order.positions.all() + assert op1.price == Decimal("23.01") + assert op1.price_includes_rounding_correction == Decimal("0.01") + assert op2.price == Decimal("23.01") + assert op2.price_includes_rounding_correction == Decimal("0.01") + assert op3.price == Decimal("23.00") + assert op3.price_includes_rounding_correction == Decimal("0.00") + assert self.order.total == Decimal("69.02") + assert self.order.transactions.count() == 7 + @classscope(attr='o') def test_reissue_invoice(self): generate_invoice(self.order) @@ -2522,7 +2597,7 @@ class OrderChangeManagerTests(TestCase): def test_recalculate_country_rate(self): prov = self.ocm._get_payment_provider() prov.settings.set('_fee_abs', Decimal('0.30')) - self.ocm._recalculate_total_and_payment_fee() + self.ocm._recalculate_rounding_total_and_payment_fee() assert self.order.total == Decimal('46.30') fee = self.order.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT) @@ -2554,7 +2629,7 @@ class OrderChangeManagerTests(TestCase): def test_recalculate_country_rate_keep_gross(self): prov = self.ocm._get_payment_provider() prov.settings.set('_fee_abs', Decimal('0.30')) - self.ocm._recalculate_total_and_payment_fee() + self.ocm._recalculate_rounding_total_and_payment_fee() assert self.order.total == Decimal('46.30') fee = self.order.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT) @@ -2584,7 +2659,7 @@ class OrderChangeManagerTests(TestCase): def test_recalculate_reverse_charge(self): prov = self.ocm._get_payment_provider() prov.settings.set('_fee_abs', Decimal('0.30')) - self.ocm._recalculate_total_and_payment_fee() + self.ocm._recalculate_rounding_total_and_payment_fee() assert self.order.total == Decimal('46.30') fee = self.order.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT) @@ -2815,6 +2890,61 @@ class OrderChangeManagerTests(TestCase): assert p.amount == Decimal('23.00') assert p.state == OrderPayment.PAYMENT_STATE_CONFIRMED + @classscope(attr='o') + def test_split_with_rounding_change(self): + # Order starts with 2*100€ tickets, but rounding corrects it to 199€. Then, it gets split, so its now 100 + 100 + # and 1€ is pending. Nasty, but we didn't choose the EN16931 rounding method… + self.order.status = Order.STATUS_PAID + self.order.tax_rounding_mode = "sum_by_net" + self.order.save() + self.op1.price = Decimal("100.00") + self.op1._calculate_tax(tax_rule=self.tr19) + self.op1.save() + self.op2.price = Decimal("100.00") + self.op2._calculate_tax(tax_rule=self.tr19) + self.op2.save() + self.order.refresh_from_db() + self.ocm.regenerate_secret(self.op1) + self.ocm.commit() # Force re-rounding + self.order.refresh_from_db() + self.ocm = OrderChangeManager(self.order, None) + + assert self.order.total == Decimal("199.99") + self.order.payments.create( + provider='manual', + state=OrderPayment.PAYMENT_STATE_CONFIRMED, + amount=self.order.total, + ) + + # Split + self.ocm.split(self.op2) + self.ocm.commit() + self.order.refresh_from_db() + self.op2.refresh_from_db() + + # First order + assert self.order.total == Decimal('100.00') + assert not self.order.fees.exists() + assert self.order.pending_sum == Decimal('0.01') + assert self.order.status == Order.STATUS_PENDING + r = self.order.refunds.last() + assert r.provider == 'offsetting' + assert r.amount == Decimal('100.00') + assert r.state == OrderRefund.REFUND_STATE_DONE + + # New order + assert self.op2.order != self.order + o2 = self.op2.order + assert o2.total == Decimal('100.00') + assert o2.status == Order.STATUS_PAID + assert o2.positions.count() == 1 + assert o2.fees.count() == 0 + assert o2.pending_sum == Decimal('0.00') + p = o2.payments.last() + assert p.provider == 'offsetting' + assert p.amount == Decimal('100.00') + assert p.state == OrderPayment.PAYMENT_STATE_CONFIRMED + @classscope(attr='o') def test_split_and_change_higher(self): self.order.status = Order.STATUS_PAID diff --git a/src/tests/base/test_pricing_rounding.py b/src/tests/base/test_pricing_rounding.py new file mode 100644 index 0000000000..ffe9eaea1b --- /dev/null +++ b/src/tests/base/test_pricing_rounding.py @@ -0,0 +1,247 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-today pretix GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +from decimal import Decimal + +import pytest + +from pretix.base.models import InvoiceAddress, OrderPosition, TaxRule +from pretix.base.services.pricing import apply_rounding + + +@pytest.fixture +def sample_lines(): + lines = [OrderPosition( + price=Decimal("100.00"), + tax_value=Decimal("15.97"), + tax_rate=Decimal("19.00"), + tax_code="S", + ) for _ in range(5)] + return lines + + +def _validate_sample_lines(sample_lines, rounding_mode): + corrections = [ + (line.tax_value_includes_rounding_correction, line.price_includes_rounding_correction) + for line in sample_lines + ] + changed = apply_rounding(rounding_mode, "EUR", sample_lines) + for line, original in zip(sample_lines, corrections): + if (line.tax_value_includes_rounding_correction, line.price_includes_rounding_correction) != original: + assert line in changed + else: + assert line not in changed + + if rounding_mode == "line": + for line in sample_lines: + assert line.price == Decimal("100.00") + assert line.tax_value == Decimal("15.97") + assert line.tax_rate == Decimal("19.00") + assert sum(line.price for line in sample_lines) == Decimal("500.00") + assert sum(line.tax_value for line in sample_lines) == Decimal("79.85") + elif rounding_mode == "sum_by_net": + for line in sample_lines: + # gross price may vary + assert line.price - line.tax_value == Decimal("84.03") + assert line.tax_rate == Decimal("19.00") + assert sum(line.price for line in sample_lines) == Decimal("499.98") + assert sum(line.tax_value for line in sample_lines) == Decimal("79.83") + assert sum(line.price - line.tax_value for line in sample_lines) == Decimal("420.15") + elif rounding_mode == "sum_by_net_keep_gross": + for line in sample_lines: + assert line.price == Decimal("100.00") + # net price may vary + assert line.tax_rate == Decimal("19.00") + assert sum(line.price for line in sample_lines) == Decimal("500.00") + assert sum(line.tax_value for line in sample_lines) == Decimal("79.83") + assert sum(line.price - line.tax_value for line in sample_lines) == Decimal("420.17") + + +@pytest.mark.django_db +def test_simple_case_by_line(sample_lines): + _validate_sample_lines(sample_lines, "line") + + +@pytest.mark.django_db +def test_simple_case_by_net(sample_lines): + _validate_sample_lines(sample_lines, "sum_by_net") + + +@pytest.mark.django_db +def test_simple_case_by_gross(sample_lines): + _validate_sample_lines(sample_lines, "sum_by_net_keep_gross") + + +@pytest.mark.django_db +def test_simple_case_switch_rounding(sample_lines): + _validate_sample_lines(sample_lines, "sum_by_net") + _validate_sample_lines(sample_lines, "sum_by_net_keep_gross") + _validate_sample_lines(sample_lines, "line") + _validate_sample_lines(sample_lines, "sum_by_net") + + +@pytest.mark.django_db +def test_revert_net_rounding_to_single_line(sample_lines): + l = OrderPosition( + price=Decimal("100.01"), + price_includes_rounding_correction=Decimal("0.01"), + tax_value=Decimal("15.98"), + tax_value_includes_rounding_correction=Decimal("0.01"), + tax_rate=Decimal("19.00"), + tax_code="S", + ) + apply_rounding("sum_by_net", "EUR", [l]) + assert l.price == Decimal("100.00") + assert l.price_includes_rounding_correction == Decimal("0.00") + assert l.tax_value == Decimal("15.97") + assert l.tax_value_includes_rounding_correction == Decimal("0.00") + assert l.tax_rate == Decimal("19.00") + + +@pytest.mark.django_db +def test_revert_net_keep_gross_rounding_to_single_line(sample_lines): + l = OrderPosition( + price=Decimal("100.00"), + price_includes_rounding_correction=Decimal("0.00"), + tax_value=Decimal("15.96"), + tax_value_includes_rounding_correction=Decimal("-0.01"), + tax_rate=Decimal("19.00"), + tax_code="S", + ) + apply_rounding("sum_by_net_keep_gross", "EUR", [l]) + assert l.price == Decimal("100.00") + assert l.price_includes_rounding_correction == Decimal("0.00") + assert l.tax_value == Decimal("15.97") + assert l.tax_value_includes_rounding_correction == Decimal("0.00") + assert l.tax_rate == Decimal("19.00") + + +@pytest.mark.django_db +@pytest.mark.parametrize("rounding_mode", [ + "sum_by_net", + "sum_by_net_keep_gross", +]) +def test_rounding_of_impossible_gross_price(rounding_mode): + l = OrderPosition( + price=Decimal("23.00"), + ) + l._calculate_tax(tax_rule=TaxRule(rate=Decimal("7.00")), invoice_address=InvoiceAddress()) + apply_rounding(rounding_mode, "EUR", [l]) + assert l.price == Decimal("23.01") + assert l.price_includes_rounding_correction == Decimal("0.01") + assert l.tax_value == Decimal("1.51") + assert l.tax_value_includes_rounding_correction == Decimal("0.01") + assert l.tax_rate == Decimal("7.00") + + +@pytest.mark.django_db +def test_round_down(): + lines = [OrderPosition( + price=Decimal("100.00"), + tax_value=Decimal("15.97"), + tax_rate=Decimal("19.00"), + tax_code="S", + ) for _ in range(5)] + assert sum(l.price for l in lines) == Decimal("500.00") + assert sum(l.tax_value for l in lines) == Decimal("79.85") + assert sum(l.price - l.tax_value for l in lines) == Decimal("420.15") + + apply_rounding("sum_by_net", "EUR", lines) + assert sum(l.price for l in lines) == Decimal("499.98") + assert sum(l.tax_value for l in lines) == Decimal("79.83") + assert sum(l.price - l.tax_value for l in lines) == Decimal("420.15") + + apply_rounding("sum_by_net_keep_gross", "EUR", lines) + assert sum(l.price for l in lines) == Decimal("500.00") + assert sum(l.tax_value for l in lines) == Decimal("79.83") + assert sum(l.price - l.tax_value for l in lines) == Decimal("420.17") + + +@pytest.mark.django_db +def test_round_up(): + lines = [OrderPosition( + price=Decimal("99.98"), + tax_value=Decimal("15.96"), + tax_rate=Decimal("19.00"), + tax_code="S", + ) for _ in range(5)] + assert sum(l.price for l in lines) == Decimal("499.90") + assert sum(l.tax_value for l in lines) == Decimal("79.80") + assert sum(l.price - l.tax_value for l in lines) == Decimal("420.10") + + apply_rounding("sum_by_net", "EUR", lines) + assert sum(l.price for l in lines) == Decimal("499.92") + assert sum(l.tax_value for l in lines) == Decimal("79.82") + assert sum(l.price - l.tax_value for l in lines) == Decimal("420.10") + + apply_rounding("sum_by_net_keep_gross", "EUR", lines) + assert sum(l.price for l in lines) == Decimal("499.90") + assert sum(l.tax_value for l in lines) == Decimal("79.82") + assert sum(l.price - l.tax_value for l in lines) == Decimal("420.08") + + +@pytest.mark.django_db +def test_round_currency_without_decimals(): + lines = [OrderPosition( + price=Decimal("9998.00"), + tax_value=Decimal("1596.00"), + tax_rate=Decimal("19.00"), + tax_code="S", + ) for _ in range(5)] + assert sum(l.price for l in lines) == Decimal("49990.00") + assert sum(l.tax_value for l in lines) == Decimal("7980.00") + assert sum(l.price - l.tax_value for l in lines) == Decimal("42010.00") + + apply_rounding("sum_by_net", "JPY", lines) + assert sum(l.price for l in lines) == Decimal("49992.00") + assert sum(l.tax_value for l in lines) == Decimal("7982.00") + assert sum(l.price - l.tax_value for l in lines) == Decimal("42010.00") + + apply_rounding("sum_by_net_keep_gross", "JPY", lines) + assert sum(l.price for l in lines) == Decimal("49990.00") + assert sum(l.tax_value for l in lines) == Decimal("7982.00") + assert sum(l.price - l.tax_value for l in lines) == Decimal("42008.00") + + +@pytest.mark.django_db +@pytest.mark.parametrize("rounding_mode", [ + "sum_by_net", + "sum_by_net_keep_gross", +]) +def test_do_not_touch_free(rounding_mode): + l1 = OrderPosition( + price=Decimal("0.00"), + ) + l1._calculate_tax(tax_rule=TaxRule(rate=Decimal("7.00")), invoice_address=InvoiceAddress()) + l2 = OrderPosition( + price=Decimal("23.00"), + ) + l2._calculate_tax(tax_rule=TaxRule(rate=Decimal("7.00")), invoice_address=InvoiceAddress()) + apply_rounding(rounding_mode, "EUR", [l1, l2]) + assert l2.price == Decimal("23.01") + assert l2.price_includes_rounding_correction == Decimal("0.01") + assert l2.tax_value == Decimal("1.51") + assert l2.tax_value_includes_rounding_correction == Decimal("0.01") + assert l2.tax_rate == Decimal("7.00") + assert l1.price == Decimal("0.00") + assert l1.price_includes_rounding_correction == Decimal("0.00") + assert l1.tax_value == Decimal("0.00") + assert l1.tax_value_includes_rounding_correction == Decimal("0.00") diff --git a/src/tests/base/test_taxrules.py b/src/tests/base/test_taxrules.py index 095d0276b1..e3cf813d6b 100644 --- a/src/tests/base/test_taxrules.py +++ b/src/tests/base/test_taxrules.py @@ -60,6 +60,19 @@ def test_from_gross_price(event): assert tp.rate == Decimal('10.00') assert tp.code == 'S/standard' + tr = TaxRule( + event=event, + rate=Decimal('19.00'), + code=None, + price_includes_tax=True, + ) + tp = tr.tax(Decimal('99.99')) + assert tp.gross == Decimal('99.99') + assert tp.net == Decimal('84.03') + assert tp.tax == Decimal('15.96') + assert tp.rate == Decimal('19.00') + assert tp.code is None + @pytest.mark.django_db def test_from_net_price(event): @@ -978,7 +991,7 @@ def test_split_fees(event): op2 = OrderPosition(price=Decimal("10.70"), item=item) op2._calculate_tax(tax_rule=tr7, invoice_address=InvoiceAddress()) of1 = OrderFee(value=Decimal("5.00"), fee_type=OrderFee.FEE_TYPE_SHIPPING) - of1._calculate_tax(tax_rule=tr7, invoice_address=InvoiceAddress()) + of1._calculate_tax(tax_rule=tr7, invoice_address=InvoiceAddress(), event=event) # Example of a 10% service fee assert split_fee_for_taxes([op1, op2], Decimal("2.26"), event) == [ diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index 2241e43004..d2e66a9ef5 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -77,7 +77,7 @@ class BaseCheckoutTestCase: plugins='pretix.plugins.stripe,pretix.plugins.banktransfer,tests.testdummy', live=True ) - self.tr19 = self.event.tax_rules.create(rate=19) + self.tr19 = self.event.tax_rules.create(rate=19, default=True) self.category = ItemCategory.objects.create(event=self.event, name="Everything", position=0) self.quota_tickets = Quota.objects.create(event=self.event, name='Tickets', size=5) self.ticket = Item.objects.create(event=self.event, name='Early-bird ticket', @@ -501,6 +501,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase): assert cr1.price == Decimal('23.00') def test_custom_tax_rules_blocked_on_fee(self): + self.tr19.default = False + self.tr19.save() self.tr7 = self.event.tax_rules.create(rate=7, default=True) self.tr7.custom_rules = json.dumps([ {'country': 'AT', 'address_type': 'business_vat_id', 'action': 'reverse'}, @@ -2352,6 +2354,252 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase): self.assertRedirects(response, '/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), target_status_code=200) + def test_rounding_sum_by_net(self): + self.event.settings.tax_rounding = "sum_by_net" + self.event.settings.set('payment_banktransfer__enabled', True) + self.ticket.default_price = Decimal("100.00") + self.ticket.save() + with scopes_disabled(): + cm = CartManager(event=self.event, cart_id=self.session_key, sales_channel=self.orga.sales_channels.get(identifier="web")) + cm.add_new_items([{ + 'item': self.ticket.pk, + 'variation': None, + 'count': 2 + }]) + cm.commit() + + response = self.client.get('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), follow=True) + assert b"199.99" in response.content + assert b"200.00" not in response.content + + response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), { + 'payment': 'banktransfer', + }, follow=True) + self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), + target_status_code=200) + + assert response.context_data['cart']['total'] == Decimal('199.99') + assert response.context_data['cart']['net_total'] == Decimal('84.03') * 2 + + response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) + doc = BeautifulSoup(response.content.decode(), "lxml") + self.assertEqual(len(doc.select(".thank-you")), 1) + with scopes_disabled(): + o = Order.objects.last() + p1 = o.payments.get() + assert p1.amount == Decimal('199.99') + assert o.total == Decimal("199.99") + op1, op2 = o.positions.all() + assert op1.price == Decimal("99.99") + assert op1.price_includes_rounding_correction == Decimal("-0.01") + assert op1.tax_value == Decimal("15.96") + assert op1.tax_value_includes_rounding_correction == Decimal("-0.01") + assert op2.price == Decimal("100.00") + assert op2.price_includes_rounding_correction == Decimal("0.00") + assert op2.tax_value == Decimal("15.97") + assert op2.tax_value_includes_rounding_correction == Decimal("0.00") + + def test_rounding_sum_by_net_keep_gross(self): + self.event.settings.tax_rounding = "sum_by_net_keep_gross" + self.event.settings.set('payment_banktransfer__enabled', True) + self.ticket.default_price = Decimal("100.00") + self.ticket.save() + with scopes_disabled(): + cm = CartManager(event=self.event, cart_id=self.session_key, sales_channel=self.orga.sales_channels.get(identifier="web")) + cm.add_new_items([{ + 'item': self.ticket.pk, + 'variation': None, + 'count': 2 + }]) + cm.commit() + + response = self.client.get('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), follow=True) + assert b"199.99" not in response.content + assert b"200.00" in response.content + + response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), { + 'payment': 'banktransfer', + }, follow=True) + self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), + target_status_code=200) + + assert response.context_data['cart']['total'] == Decimal('200.00') + assert response.context_data['cart']['net_total'] == Decimal('84.03') + Decimal('84.04') + + response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) + doc = BeautifulSoup(response.content.decode(), "lxml") + self.assertEqual(len(doc.select(".thank-you")), 1) + with scopes_disabled(): + o = Order.objects.last() + p1 = o.payments.get() + assert p1.amount == Decimal('200.00') + assert o.total == Decimal("200.00") + op1, op2 = o.positions.all() + assert op1.price == Decimal("100.00") + assert op1.price_includes_rounding_correction == Decimal("0.00") + assert op1.tax_value == Decimal("15.96") + assert op1.tax_value_includes_rounding_correction == Decimal("-0.01") + assert op2.price == Decimal("100.00") + assert op2.price_includes_rounding_correction == Decimal("0.00") + assert op2.tax_value == Decimal("15.97") + assert op2.tax_value_includes_rounding_correction == Decimal("0.00") + + def test_rounding_line(self): + self.event.settings.tax_rounding = "line" + self.event.settings.set('payment_banktransfer__enabled', True) + self.ticket.default_price = Decimal("100.00") + self.ticket.save() + with scopes_disabled(): + cm = CartManager(event=self.event, cart_id=self.session_key, sales_channel=self.orga.sales_channels.get(identifier="web")) + cm.add_new_items([{ + 'item': self.ticket.pk, + 'variation': None, + 'count': 2 + }]) + cm.commit() + + response = self.client.get('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), follow=True) + assert b"199.99" not in response.content + assert b"200.00" in response.content + + response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), { + 'payment': 'banktransfer', + }, follow=True) + self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), + target_status_code=200) + + assert response.context_data['cart']['total'] == Decimal('200.00') + assert response.context_data['cart']['net_total'] == Decimal('84.03') * 2 + + response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) + doc = BeautifulSoup(response.content.decode(), "lxml") + self.assertEqual(len(doc.select(".thank-you")), 1) + with scopes_disabled(): + o = Order.objects.last() + p1 = o.payments.get() + assert p1.amount == Decimal('200.00') + assert o.total == Decimal("200.00") + op1, op2 = o.positions.all() + assert op1.price == Decimal("100.00") + assert op1.price_includes_rounding_correction == Decimal("0.00") + assert op1.tax_value == Decimal("15.97") + assert op1.tax_value_includes_rounding_correction == Decimal("0.00") + assert op2.price == Decimal("100.00") + assert op2.price_includes_rounding_correction == Decimal("0.00") + assert op2.tax_value == Decimal("15.97") + assert op2.tax_value_includes_rounding_correction == Decimal("0.00") + + def test_rounding_sum_by_net_with_payment_fee(self): + self.event.settings.tax_rounding = "sum_by_net" + self.event.settings.tax_rule_payment = "default" + self.event.settings.set('payment_banktransfer__enabled', True) + self.event.settings.set('payment_banktransfer__fee_abs', Decimal("100.00")) + self.ticket.default_price = Decimal("100.00") + self.ticket.save() + with scopes_disabled(): + cm = CartManager(event=self.event, cart_id=self.session_key, sales_channel=self.orga.sales_channels.get(identifier="web")) + cm.add_new_items([{ + 'item': self.ticket.pk, + 'variation': None, + 'count': 1 + }]) + cm.commit() + + response = self.client.get('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), follow=True) + assert b"100.00" in response.content + assert b"99.99" not in response.content + assert b"199.99" not in response.content + + response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), { + 'payment': 'banktransfer', + }, follow=True) + self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), + target_status_code=200) + + assert response.context_data['cart']['total'] == Decimal('199.99') + assert response.context_data['cart']['net_total'] == Decimal('84.03') * 2 + + response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) + doc = BeautifulSoup(response.content.decode(), "lxml") + self.assertEqual(len(doc.select(".thank-you")), 1) + with scopes_disabled(): + o = Order.objects.last() + p1 = o.payments.get() + assert p1.amount == Decimal('199.99') + assert o.total == Decimal("199.99") + op1 = o.positions.get() + of1 = o.fees.get() + assert op1.price == Decimal("99.99") + assert op1.price_includes_rounding_correction == Decimal("-0.01") + assert op1.tax_value == Decimal("15.96") + assert op1.tax_value_includes_rounding_correction == Decimal("-0.01") + assert of1.price == Decimal("100.00") + assert of1.price_includes_rounding_correction == Decimal("0.00") + assert of1.tax_value == Decimal("15.97") + assert of1.tax_value_includes_rounding_correction == Decimal("0.00") + + def test_rounding_sum_by_net_with_payment_fee_that_makes_card_insufficient(self): + # Our built-in gift card payment does not actually support setting a payment fee, but we still want to + # test the core behavior in case a gift-card plugin does + gc = self.orga.issued_gift_cards.create(currency="EUR") + gc.transactions.create(value=199.96, acceptor=self.orga) + self.event.settings.set('payment_banktransfer__enabled', True) + self.event.settings.set('payment_giftcard__fee_abs', "99.98") + self.event.settings.set('payment_giftcard__fee_reverse_calc', False) + self.event.settings.tax_rounding = "sum_by_net" + self.event.settings.tax_rule_payment = "default" + self.ticket.default_price = Decimal("99.98") + self.ticket.save() + with scopes_disabled(): + cm = CartManager(event=self.event, cart_id=self.session_key, sales_channel=self.orga.sales_channels.get(identifier="web")) + cm.add_new_items([{ + 'item': self.ticket.pk, + 'variation': None, + 'count': 1 + }]) + cm.commit() + + response = self.client.get('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), follow=True) + assert b"99.98" in response.content + assert b"99.99" not in response.content + assert b"199.97" not in response.content + + response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), { + 'payment': 'giftcard', + 'payment_giftcard-code': gc.secret + }, follow=True) + self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), + target_status_code=200) + + response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), { + 'payment': 'banktransfer', + }, follow=True) + self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), + target_status_code=200) + + assert response.context_data['cart']['total'] == Decimal('199.97') + assert response.context_data['cart']['net_total'] == Decimal('84.02') * 2 + + response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) + doc = BeautifulSoup(response.content.decode(), "lxml") + self.assertEqual(len(doc.select(".thank-you")), 1) + with scopes_disabled(): + o = Order.objects.last() + p1, p2 = o.payments.all() + assert p1.amount == Decimal('199.96') + assert p2.amount == Decimal('0.01') + assert o.total == Decimal("199.97") + op1 = o.positions.get() + of1 = o.fees.get() + assert op1.price == Decimal("99.99") + assert op1.price_includes_rounding_correction == Decimal("0.01") + assert op1.tax_value == Decimal("15.97") + assert op1.tax_value_includes_rounding_correction == Decimal("0.01") + assert of1.price == Decimal("99.98") + assert of1.price_includes_rounding_correction == Decimal("0.00") + assert of1.tax_value == Decimal("15.96") + assert of1.tax_value_includes_rounding_correction == Decimal("0.00") + def test_subevent(self): self.event.has_subevents = True self.event.save() diff --git a/src/tests/presale/test_orders.py b/src/tests/presale/test_orders.py index be4d7d0336..d2ad44e711 100644 --- a/src/tests/presale/test_orders.py +++ b/src/tests/presale/test_orders.py @@ -1334,6 +1334,56 @@ class OrdersTest(BaseOrdersTest): p.refresh_from_db() assert p.state == OrderPayment.PAYMENT_STATE_CREATED + def test_change_paymentmethod_with_rounding_change(self): + tr19 = self.event.tax_rules.create( + name='VAT', + rate=Decimal('19.00'), + default=True + ) + self.ticket.tax_rule = tr19 + self.ticket.save() + self.ticket_pos.price = Decimal("100.00") + self.ticket_pos.tax_rule = tr19 + self.ticket_pos._calculate_tax() + self.ticket_pos.save() + self.order.total = Decimal("100.00") + self.order.tax_rounding_mode = "sum_by_net" + self.order.save() + + self.event.settings.tax_rounding = "sum_by_net" + self.event.settings.set('payment_banktransfer__enabled', True) + self.event.settings.set('payment_testdummy__enabled', True) + self.event.settings.set('payment_testdummy__fee_reverse_calc', False) + self.event.settings.set('payment_testdummy__fee_abs', '100.00') + + response = self.client.get( + '/%s/%s/order/%s/%s/pay/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), + ) + assert 'Test dummy' in response.content.decode() + assert '+ €100.00' in response.content.decode() + response = self.client.post( + '/%s/%s/order/%s/%s/pay/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), + { + 'payment': 'testdummy' + }, follow=True + ) + assert 'Total: €199.99' in response.content.decode() + self.order.refresh_from_db() + with scopes_disabled(): + assert self.order.payments.last().provider == 'testdummy' + fee = self.order.fees.filter(fee_type=OrderFee.FEE_TYPE_PAYMENT).last() + assert fee.value == Decimal('100.00') + assert fee.tax_value == Decimal('15.97') + self.ticket_pos.refresh_from_db() + assert self.ticket_pos.price == Decimal("99.99") + assert self.ticket_pos.price_includes_rounding_correction == Decimal("-0.01") + self.order.refresh_from_db() + assert self.order.total == Decimal('199.99') + p = self.order.payments.last() + assert p.provider == 'testdummy' + assert p.state == OrderPayment.PAYMENT_STATE_CREATED + assert p.amount == Decimal('199.99') + def test_change_paymentmethod_to_same(self): with scopes_disabled(): p_old = self.order.payments.create( diff --git a/src/tests/testdummy/payment.py b/src/tests/testdummy/payment.py index 827ce3de91..d348c269e1 100644 --- a/src/tests/testdummy/payment.py +++ b/src/tests/testdummy/payment.py @@ -35,7 +35,7 @@ class DummyPaymentProvider(BasePaymentProvider): abort_pending_allowed = False def payment_is_valid_session(self, request: HttpRequest) -> bool: - pass + return True def checkout_confirm_render(self, request) -> str: pass