From 4d0895a82f9b9106c4f6c918889b4c1635c97e40 Mon Sep 17 00:00:00 2001 From: nnhathung <139526485+nnhathung@users.noreply.github.com> Date: Tue, 12 Sep 2023 16:18:10 +0700 Subject: [PATCH] feature-5865 Able to book more discounted or access restricted tickets than allowed (#9041) * fix issue user request check in not admin * feature-5865: Able to book more discounted or access restricted tickets than allowed * feature-5865: Able to book more discounted or access restricted tickets than allowed * fix ut * fix ut * fix ut * fix ut * fix ut * feature-5865: Able to book more discounted or access restricted tickets than allowed * feature-5865: Able to book more discounted or access restricted tickets than allowed * feature-5865: Able to book more discounted or access restricted tickets than allowed * fix complex function * fix complex function * fix complex function * fix complex function * add foreign key name * fix complex code * fix complex code * fix complex code * fix complex code * fix complex code * feature-5865: Merge code development * feature-5865: Able to book more discounted or access restricted tickets than allowed * fea-5865: update migration --------- Co-authored-by: Hieu Lam - TMA <135117837+lthanhhieu@users.noreply.github.com> Co-authored-by: lthanhhieu --- app/api/custom/orders.py | 81 +++++- app/api/custom/schema/order_amount.py | 5 + app/api/helpers/order.py | 232 ++++++++++++------ app/api/helpers/ticketing.py | 34 ++- app/api/schema/attendees.py | 2 + app/models/access_code.py | 20 ++ app/models/discount_code.py | 3 +- app/models/order.py | 7 + app/models/ticket_holder.py | 2 + .../rev-2023-08-11-00:00:08-414c776ae509_.py | 34 +++ .../rev-2023-08-11-15:26:07-1af4cc4f7cd5_.py | 2 +- .../order/test_calculate_order_amount.py | 28 ++- .../api/helpers/order/test_create_order.py | 6 +- .../integration/api/helpers/test_ticketing.py | 24 +- tests/factories/attendee.py | 1 + 15 files changed, 379 insertions(+), 102 deletions(-) create mode 100644 migrations/versions/rev-2023-08-11-00:00:08-414c776ae509_.py diff --git a/app/api/custom/orders.py b/app/api/custom/orders.py index a20fb9e95e..14f3bc391c 100644 --- a/app/api/custom/orders.py +++ b/app/api/custom/orders.py @@ -7,7 +7,7 @@ from sqlalchemy.orm.exc import NoResultFound from app.api.custom.schema.order_amount import OrderAmountInputSchema -from app.api.helpers.db import safe_query, save_to_db +from app.api.helpers.db import safe_query, safe_query_by_id, save_to_db from app.api.helpers.errors import ForbiddenError, NotFoundError, UnprocessableEntityError from app.api.helpers.mail import send_email_to_attendees from app.api.helpers.order import ( @@ -21,6 +21,8 @@ from app.api.schema.orders import OrderSchema from app.extensions.limiter import limiter from app.models import db +from app.models.access_code import AccessCode +from app.models.discount_code import DiscountCode from app.models.order import Order, OrderTicket from app.models.ticket import Ticket from app.models.ticket_holder import TicketHolder @@ -91,7 +93,11 @@ def calculate_amount(): data, errors = OrderAmountInputSchema().load(request.get_json()) if errors: return make_response(jsonify(errors), 422) - return jsonify(calculate_order_amount(data['tickets'], data.get('discount_code'))) + return jsonify( + calculate_order_amount( + data['tickets'], data.get('discount_verify'), data.get('discount_code') + ) + ) @order_blueprint.route('/create-order', methods=['POST']) @@ -102,7 +108,9 @@ def create_order(): return make_response(jsonify(errors), 422) tickets_dict = data['tickets'] - order_amount = calculate_order_amount(tickets_dict, data.get('discount_code')) + order_amount = calculate_order_amount( + tickets_dict, data.get('discount_verify'), data.get('discount_code') + ) ticket_ids = {ticket['id'] for ticket in tickets_dict} ticket_map = {int(ticket['id']): ticket for ticket in tickets_dict} tickets = ( @@ -116,14 +124,75 @@ def create_order(): ) event = tickets[0].event - + discount_code = None + access_code = None + discount_threshold = 0 + access_threshold = 0 + current_access_usage_count = 0 + if data.get('discount_code') and ( + isinstance(data.get('discount_code'), int) + or ( + isinstance(data.get('discount_code'), str) + and data.get('discount_code').isdigit() + ) + ): + # Discount Code ID is passed + discount_code = safe_query_by_id(DiscountCode, data.get('discount_code')) + if discount_code is not None: + current_discount_usage_count = discount_code.confirmed_attendees_count + discount_threshold = ( + discount_code.tickets_number - current_discount_usage_count + ) + if data.get('access_code') and ( + isinstance(data.get('access_code'), int) + or ( + isinstance(data.get('access_code'), str) and data.get('access_code').isdigit() + ) + ): + # Access Code check + access_code = safe_query_by_id(AccessCode, data.get('access_code')) + if access_code is not None: + current_access_usage_count = access_code.confirmed_attendees_count + access_threshold = access_code.tickets_number - current_access_usage_count try: attendees = [] for ticket in tickets: for _ in range(ticket_map[ticket.id]['quantity']): ticket.raise_if_unavailable() + is_discount_applied = False + is_access_code_applied = False + if ( + discount_code + and (ticket in discount_code.tickets) + and (discount_threshold > 0) + ): + is_discount_applied = True + discount_threshold -= 1 + + if ( + access_code + and (ticket in access_code.tickets) + and access_threshold >= 0 + ): + if access_threshold == 0: + raise UnprocessableEntityError( + {'source': 'access_code'}, + f"Access code for ticket {ticket.name} is exhausted, " + f"only {access_code.tickets_number - current_access_usage_count} " + "quantity is available", + ) + is_access_code_applied = True + access_threshold -= 1 + attendees.append( - TicketHolder(firstname='', lastname='', ticket=ticket, event=event) + TicketHolder( + firstname='', + lastname='', + ticket=ticket, + event=event, + is_discount_applied=is_discount_applied, + is_access_code_applied=is_access_code_applied, + ) ) db.session.commit() except Exception as e: @@ -142,6 +211,7 @@ def create_order(): amount=order_amount['total'], event=event, discount_code_id=data.get('discount_code'), + access_code_id=data.get('access_code'), ticket_holders=attendees, ) db.session.commit() @@ -184,7 +254,6 @@ def ticket_attendee_pdf(attendee_id): @order_blueprint.route('//verify', methods=['POST']) def verify_order_payment(order_identifier): - order = Order.query.filter_by(identifier=order_identifier).first() if order.payment_mode == 'stripe': diff --git a/app/api/custom/schema/order_amount.py b/app/api/custom/schema/order_amount.py index 14eb5378f6..50756e3fa1 100644 --- a/app/api/custom/schema/order_amount.py +++ b/app/api/custom/schema/order_amount.py @@ -4,10 +4,15 @@ class TicketSchema(Schema): id = fields.Integer(required=True) quantity = fields.Integer(default=1) + quantity_discount = fields.Integer(allow_none=True) price = fields.Float(allow_none=True) class OrderAmountInputSchema(Schema): tickets = fields.Nested(TicketSchema, many=True) discount_code = fields.Integer(load_from='discount-code') + access_code = fields.Integer(load_from='access-code') amount = fields.Float(allow_none=True) + discount_verify = fields.Boolean( + required=False, default=True, load_from='discount-verify' + ) diff --git a/app/api/helpers/order.py b/app/api/helpers/order.py index f028c0b20b..0b2de0f102 100644 --- a/app/api/helpers/order.py +++ b/app/api/helpers/order.py @@ -136,15 +136,23 @@ def create_pdf_tickets_for_holder(order): tickets = [] for order_ticket in order_tickets: + attendee_count = get_count( + TicketHolder.query.filter_by(order_id=order_ticket.order.id) + .filter_by(ticket_id=order_ticket.ticket.id) + .filter_by(is_discount_applied=True) + ) ticket = dict( id=order_ticket.ticket.id, price=order_ticket.price, quantity=order_ticket.quantity, + quantity_discount=attendee_count, ) tickets.append(ticket) # calculate order amount using helper function - order_amount = calculate_order_amount(tickets, discount_code=order.discount_code) + order_amount = calculate_order_amount( + tickets, verify_discount=False, discount_code=order.discount_code + ) create_save_pdf( render_template( @@ -238,19 +246,30 @@ def create_onsite_attendees_for_order(data): del data['on_site_tickets'] -def calculate_order_amount(tickets, discount_code=None): - from app.api.helpers.ticketing import validate_discount_code, validate_tickets +def calculate_order_amount(tickets, verify_discount=True, discount_code=None): + from app.api.helpers.ticketing import ( + is_discount_available, + validate_discount_code, + validate_tickets, + ) from app.models.discount_code import DiscountCode ticket_ids = {ticket['id'] for ticket in tickets} ticket_map = {int(ticket['id']): ticket for ticket in tickets} fetched_tickets = validate_tickets(ticket_ids) + quantity_discount: dict = {'numb_no_discount': 0, 'numb_discount': 0} if tickets and discount_code: discount_code = validate_discount_code(discount_code, tickets=tickets) + quantity_discount = is_discount_available( + discount_code, + tickets=tickets, + quantity_discount=quantity_discount, + verify_discount=verify_discount, + ) event = tax = tax_included = fees = None - total_amount = total_tax = total_discount = 0.0 + total_amount = total_discount = 0.0 ticket_list = [] for ticket in fetched_tickets: ticket_tax = discounted_tax = 0.0 @@ -260,39 +279,25 @@ def calculate_order_amount(tickets, discount_code=None): ticket_fee = 0.0 quantity = ticket_info.get('quantity', 1) # Default to single ticket + if ticket_info.get('quantity_discount'): + quantity_discount['numb_discount'] = ticket_info.get('quantity_discount') + discount_quantity = ( + quantity + if quantity_discount['numb_discount'] >= quantity + else quantity_discount['numb_discount'] + ) if not event: - event = ticket.event - - if event.deleted_at: - raise ObjectNotFound( - {'pointer': 'tickets/event'}, f'Event: {event.id} not found' - ) - - fees = TicketFees.query.filter_by(currency=event.payment_currency).first() - + event, fees = get_event_fee(ticket) if not tax and event.tax: tax = event.tax tax_included = tax.is_tax_included_in_price - - if ticket.type in ['donation', 'donationRegistration']: - price = ticket_info.get('price') - if not price or price > ticket.max_price or price < ticket.min_price: - raise UnprocessableEntityError( - {'pointer': 'tickets/price'}, - f"Price for donation ticket should be present and within range " - f"{ticket.min_price} to {ticket.max_price}", - ) - else: - price = ( - ticket.price if ticket.type not in ['free', 'freeRegistration'] else 0.0 - ) - + price = get_price(ticket, ticket_info) if tax: - if tax_included: - ticket_tax = price - price / (1 + tax.rate / 100) - else: - ticket_tax = price * tax.rate / 100 - + ticket_tax = ( + price - price / (1 + tax.rate / 100) + if tax_included + else price * tax.rate / 100 + ) if discount_code and ticket.type not in ['free', 'freeRegistration']: code = ( DiscountCode.query.with_parent(ticket) @@ -301,37 +306,31 @@ def calculate_order_amount(tickets, discount_code=None): ) if code: if discount_code.id == code.id: - if code.type == 'amount': - discount_amount = min(code.value, price) - discount_percent = (discount_amount / price) * 100 - if tax: - if tax_included: - discounted_tax = (price - discount_amount) - ( - price - discount_amount - ) / (1 + tax.rate / 100) - else: - discounted_tax = ( - (price - discount_amount) * tax.rate / 100 - ) - else: - discount_amount = (price * code.value) / 100 - if tax: - discounted_tax = ticket_tax - (ticket_tax * code.value / 100) - discount_percent = code.value - discount_data = { - 'code': discount_code.code, - 'percent': round(discount_percent, 2), - 'amount': round(discount_amount, 2), - 'total': round(discount_amount * quantity, 2), - 'type': code.type, - } - - total_discount += round(discount_amount * quantity, 2) + ( + discount_amount, + discount_percent, + discounted_tax, + ) = get_discount_amount( + code, price, tax_included, tax, ticket_tax, discounted_tax + ) + discount_data = get_discount_data( + discount_code, + discount_percent, + discount_amount, + discount_quantity, + code, + quantity_discount, + ) + quantity_discount['numb_discount'] = ( + quantity_discount['numb_discount'] - quantity + ) + + total_discount += round(discount_amount * discount_quantity, 2) if fees and not ticket.is_fee_absorbed: ticket_fee = fees.service_fee * (price * quantity) / 100 - if ticket_fee > fees.maximum_fee: - ticket_fee = fees.maximum_fee - sub_total = ticket_fee + (price - discount_amount) * quantity + ticket_fee = min(ticket_fee, fees.maximum_fee) + sub_total = ticket_fee + (price - discount_amount) * discount_quantity + sub_total += price * max(0, quantity - discount_quantity) total_amount = total_amount + sub_total ticket_list.append( { @@ -350,17 +349,7 @@ def calculate_order_amount(tickets, discount_code=None): sub_total = total_amount tax_dict = None if tax: - if tax_included: - total_tax = total_amount - total_amount / (1 + tax.rate / 100) - else: - total_tax = total_amount * tax.rate / 100 - total_amount += total_tax - tax_dict = dict( - included=tax_included, - amount=round(total_tax, 2), - percent=tax.rate if tax else 0.0, - name=tax.name, - ) + tax_dict, total_amount = get_tax_amount(tax_included, total_amount, tax) return dict( tax=tax_dict, @@ -392,3 +381,102 @@ def on_order_completed(order): ) send_order_purchase_organizer_email(order, organizer_set) notify_ticket_purchase_organizer(order) + + +def get_price(ticket, ticket_info): + if ticket.type in ['donation', 'donationRegistration']: + price = ticket_info.get('price') + if not price or price > ticket.max_price or price < ticket.min_price: + raise UnprocessableEntityError( + {'pointer': 'tickets/price'}, + f"Price for donation ticket should be present and within range " + f"{ticket.min_price} to {ticket.max_price}", + ) + else: + price = ticket.price if ticket.type not in ['free', 'freeRegistration'] else 0.0 + return price + + +def get_discount_data( + discount_code, + discount_percent, + discount_amount, + discount_quantity, + code, + quantity_discount, +): + """ + Get discount data for calculate price + @param discount_code: discount code + @param discount_percent: discount percent + @param discount_amount: discount amount + @param discount_quantity: discount quantity + @param code: code + @param quantity_discount: quantity discount + @return: discount data + """ + discount_data = { + 'code': discount_code.code, + 'percent': round(discount_percent, 2), + 'amount': round(discount_amount, 2), + 'total': round(discount_amount * discount_quantity, 2), + 'type': code.type, + } + if int(quantity_discount.get('numb_no_discount')) > 0: + discount_data[ + 'warning' + ] = 'Your order not fully discount due to discount code usage is exhausted.' + return discount_data + + +def get_tax_amount(tax_included, total_amount, tax): + """ + Get tax amount for calculate price + @param tax_included: tax included + @param total_amount: total amount + @param tax: tax model + @return: tax and amount after tax + """ + if tax_included: + total_tax = total_amount - total_amount / (1 + tax.rate / 100) + else: + total_tax = total_amount * tax.rate / 100 + total_amount += total_tax + tax_dict = dict( + included=tax_included, + amount=round(total_tax, 2), + percent=tax.rate if tax else 0.0, + name=tax.name, + ) + return tax_dict, total_amount + + +def get_event_fee(ticket): + event = ticket.event + + if event.deleted_at: + raise ObjectNotFound({'pointer': 'tickets/event'}, f'Event: {event.id} not found') + + fees = TicketFees.query.filter_by(currency=event.payment_currency).first() + return event, fees + + +def get_discount_amount(code, price, tax_included, tax, ticket_tax, discounted_tax): + if code.type == 'amount': + discount_amount = min(code.value, price) + discount_percent = (discount_amount / price) * 100 + if tax: + tax_rate = tax.rate / 100 + tax_factor = 1 + tax_rate + discounted_price = price - discount_amount + discounted_tax = ( + discounted_price * tax_rate / tax_factor + if tax_included + else discounted_price * tax_rate + ) + else: + discount_amount = (price * code.value) / 100 + if tax: + discounted_tax = ticket_tax - (ticket_tax * code.value / 100) + discount_percent = code.value + return discount_amount, discount_percent, discounted_tax diff --git a/app/api/helpers/ticketing.py b/app/api/helpers/ticketing.py index 5001fdf9a4..32a107d0c6 100644 --- a/app/api/helpers/ticketing.py +++ b/app/api/helpers/ticketing.py @@ -146,11 +146,22 @@ def validate_discount_code( return discount_code -def is_discount_available(discount_code, tickets=None, ticket_holders=None): +def is_discount_available( + discount_code, + tickets=None, + ticket_holders=None, + quantity_discount=None, + verify_discount=True, +): """ Validation of discount code belonging to the tickets and events should be done before calling this method """ + if quantity_discount is None: + quantity_discount: dict = { + 'numb_no_discount': 0, + 'numb_discount': 0, + } qty = 0 # TODO(Areeb): Extremely confusing here what should we do about deleted tickets here ticket_ids = [ticket.id for ticket in discount_code.tickets] @@ -171,13 +182,9 @@ def is_discount_available(discount_code, tickets=None, ticket_holders=None): max_quantity = qty if discount_code.max_quantity < 0 else discount_code.max_quantity - available = ( - (qty + old_holders) <= discount_code.tickets_number - and discount_code.min_quantity <= qty <= max_quantity - ) - if not available: + if not discount_code.min_quantity <= qty <= max_quantity: logger.warning( - "Discount code usage exhausted", + "Discount code is not applied with your quantity.", extra=dict( discount_code=discount_code, ticket_ids=ticket_ids, @@ -186,7 +193,18 @@ def is_discount_available(discount_code, tickets=None, ticket_holders=None): old_holders=old_holders, ), ) - return available + if not (old_holders < discount_code.tickets_number) and verify_discount: + raise UnprocessableEntityError( + {'pointer': 'discount_sold_out'}, "Discount tickets sold out." + ) + if (qty + old_holders - discount_code.tickets_number) > 0: + quantity_discount['numb_no_discount'] = ( + qty + old_holders - discount_code.tickets_number + ) + quantity_discount['numb_discount'] = discount_code.tickets_number - old_holders + else: + quantity_discount['numb_discount'] = qty + return quantity_discount class TicketingManager: diff --git a/app/api/schema/attendees.py b/app/api/schema/attendees.py index 639a98cf2e..8637d4dba6 100644 --- a/app/api/schema/attendees.py +++ b/app/api/schema/attendees.py @@ -82,6 +82,8 @@ def validate_json(self, data, original_data): is_consent_form_field_email = fields.Boolean(allow_none=True) is_badge_printed = fields.Boolean(allow_none=True) badge_printed_at = fields.DateTime(allow_none=True) + is_discount_applied = fields.Boolean(allow_none=True) + is_access_code_applied = fields.Boolean(allow_none=True) tag_id = fields.Int(allow_none=True) event = Relationship( self_view='v1.attendee_event', diff --git a/app/models/access_code.py b/app/models/access_code.py index 36056d55fb..bc8ba445da 100644 --- a/app/models/access_code.py +++ b/app/models/access_code.py @@ -3,8 +3,11 @@ from sqlalchemy.sql import func +from app.api.helpers.db import get_count from app.models import db from app.models.base import SoftDeletionModel +from app.models.order import Order +from app.models.ticket_holder import TicketHolder @dataclass(init=False, unsafe_hash=True) @@ -44,3 +47,20 @@ def get_service_name(): @property def valid_expire_time(self): return self.valid_till or self.event.ends_at + + def get_confirmed_attendees_query(self): + """ + Get list of attendee who complete order using access code + @return: list of attendee + """ + return ( + TicketHolder.query.filter_by(deleted_at=None) + .filter_by(is_access_code_applied=True) + .join(Order) + .filter_by(access_code_id=self.id) + .filter(Order.status.in_(['completed', 'placed', 'pending', 'initializing'])) + ) + + @property + def confirmed_attendees_count(self) -> int: + return get_count(self.get_confirmed_attendees_query()) diff --git a/app/models/discount_code.py b/app/models/discount_code.py index 0456383b22..f19d306730 100644 --- a/app/models/discount_code.py +++ b/app/models/discount_code.py @@ -46,9 +46,10 @@ def __repr__(self) -> str: def get_confirmed_attendees_query(self): return ( TicketHolder.query.filter_by(deleted_at=None) + .filter_by(is_discount_applied=True) .join(Order) .filter_by(discount_code_id=self.id) - .filter(Order.status.in_(['completed', 'placed'])) + .filter(Order.status.in_(['completed', 'placed', 'pending', 'initializing'])) ) @property diff --git a/app/models/order.py b/app/models/order.py index a904391cad..9d21eed228 100644 --- a/app/models/order.py +++ b/app/models/order.py @@ -96,6 +96,13 @@ class Status: default=None, ) discount_code = db.relationship('DiscountCode', backref='orders') + access_code_id = db.Column( + db.Integer, + db.ForeignKey('access_codes.id', ondelete='SET NULL'), + nullable=True, + default=None, + ) + access_code = db.relationship('AccessCode', backref='orders') event = db.relationship('Event', backref='orders') user = db.relationship('User', backref='orders', foreign_keys=[user_id]) diff --git a/app/models/ticket_holder.py b/app/models/ticket_holder.py index 7a035095eb..3501b54e7a 100644 --- a/app/models/ticket_holder.py +++ b/app/models/ticket_holder.py @@ -86,6 +86,8 @@ class TicketHolder(SoftDeletionModel): is_consent_form_field_email: bool = db.Column(db.Boolean, default=False) is_badge_printed: bool = db.Column(db.Boolean, default=False) badge_printed_at: datetime = db.Column(db.DateTime(timezone=True)) + is_discount_applied: bool = db.Column(db.Boolean, default=False) + is_access_code_applied: bool = db.Column(db.Boolean, default=False) tag_id: int = db.Column(db.Integer, db.ForeignKey('tags.id', ondelete='CASCADE')) tag = db.relationship('Tag', backref='ticket_holders') diff --git a/migrations/versions/rev-2023-08-11-00:00:08-414c776ae509_.py b/migrations/versions/rev-2023-08-11-00:00:08-414c776ae509_.py new file mode 100644 index 0000000000..47b74d5e42 --- /dev/null +++ b/migrations/versions/rev-2023-08-11-00:00:08-414c776ae509_.py @@ -0,0 +1,34 @@ +"""empty message + +Revision ID: 414c776ae509 +Revises: 24271525a263 +Create Date: 2023-08-10 00:00:08.837497 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '414c776ae509' +down_revision = '24271525a263' + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('orders', sa.Column('access_code_id', sa.Integer(), nullable=True)) + op.create_foreign_key(u'orders_access_code_id_fkey', 'orders', 'access_codes', ['access_code_id'], ['id'], ondelete='SET NULL') + op.add_column('ticket_holders', sa.Column('is_discount_applied', sa.Boolean(), nullable=True)) + op.add_column('ticket_holders', sa.Column('is_access_code_applied', sa.Boolean(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('ticket_holders', 'is_access_code_applied') + op.drop_column('ticket_holders', 'is_discount_applied') + op.drop_constraint(u'orders_access_code_id_fkey', 'orders', type_='foreignkey') + op.drop_column('orders', 'access_code_id') + # ### end Alembic commands ### + diff --git a/migrations/versions/rev-2023-08-11-15:26:07-1af4cc4f7cd5_.py b/migrations/versions/rev-2023-08-11-15:26:07-1af4cc4f7cd5_.py index cc3187400f..bc72fae59f 100644 --- a/migrations/versions/rev-2023-08-11-15:26:07-1af4cc4f7cd5_.py +++ b/migrations/versions/rev-2023-08-11-15:26:07-1af4cc4f7cd5_.py @@ -12,7 +12,7 @@ # revision identifiers, used by Alembic. revision = '1af4cc4f7cd5' -down_revision = '24271525a263' +down_revision = '414c776ae509' def upgrade(): diff --git a/tests/all/integration/api/helpers/order/test_calculate_order_amount.py b/tests/all/integration/api/helpers/order/test_calculate_order_amount.py index a923a3ac45..7e8f136110 100644 --- a/tests/all/integration/api/helpers/order/test_calculate_order_amount.py +++ b/tests/all/integration/api/helpers/order/test_calculate_order_amount.py @@ -204,7 +204,9 @@ def test_discount_code(db): ) db.session.commit() - amount_data = calculate_order_amount([{'id': ticket.id}], discount_code.id) + amount_data = calculate_order_amount( + [{'id': ticket.id}], discount_code=discount_code.id + ) assert amount_data['total'] == 90.0 assert amount_data['tax'] is None @@ -255,7 +257,7 @@ def test_multiple_tickets_discount(db): [ticket_a, ticket_b, ticket_c, ticket_d], [2, 3, 1, 2] ) - amount_data = calculate_order_amount(tickets_dict, discount.id) + amount_data = calculate_order_amount(tickets_dict, discount_code=discount.id) assert amount_data['total'] == 1115.0 assert amount_data['discount'] == 793.7 @@ -284,7 +286,9 @@ def test_discount_code_amount_type(db): ) db.session.commit() - amount_data = calculate_order_amount([{'id': ticket.id}], discount_code.id) + amount_data = calculate_order_amount( + [{'id': ticket.id}], discount_code=discount_code.id + ) assert amount_data['total'] == 50.0 assert amount_data['discount'] == 50.0 @@ -303,7 +307,9 @@ def test_discount_code_more_amount(db): ) db.session.commit() - amount_data = calculate_order_amount([{'id': ticket.id}], discount_code.id) + amount_data = calculate_order_amount( + [{'id': ticket.id}], discount_code=discount_code.id + ) assert amount_data['total'] == 0.0 assert amount_data['discount'] == 50.0 @@ -350,7 +356,7 @@ def test_tax_included_with_discount(db): discount_code = DiscountCodeTicketSubFactory(type='percent', value=10.0, tickets=[]) tickets_dict = _create_taxed_tickets(db, discount_code=discount_code) - amount_data = calculate_order_amount(tickets_dict, discount_code) + amount_data = calculate_order_amount(tickets_dict, discount_code=discount_code) assert amount_data['sub_total'] == 4021.87 assert amount_data['total'] == 4021.87 @@ -365,7 +371,7 @@ def test_tax_excluded_with_discount(db): db, tax_included=False, discount_code=discount_code ) - amount_data = calculate_order_amount(tickets_dict, discount_code) + amount_data = calculate_order_amount(tickets_dict, discount_code=discount_code) assert amount_data['sub_total'] == 4021.87 assert amount_data['total'] == 4745.81 @@ -397,7 +403,7 @@ def test_ticket_with_deleted_discount_code(db): db.session.commit() with pytest.raises(ObjectNotFound): - calculate_order_amount([{'id': ticket.id}], discount.id) + calculate_order_amount([{'id': ticket.id}], discount_code=discount.id) def test_ticket_with_different_discount_code(db): @@ -406,7 +412,7 @@ def test_ticket_with_different_discount_code(db): db.session.commit() with pytest.raises(UnprocessableEntityError, match='Invalid Discount Code'): - calculate_order_amount([{'id': ticket.id}], discount.id) + calculate_order_amount([{'id': ticket.id}], discount_code=discount.id) def test_request_calculate_order_amount(client, db): @@ -420,7 +426,11 @@ def test_request_calculate_order_amount(client, db): '/v1/orders/calculate-amount', content_type='application/json', data=json.dumps( - {'tickets': tickets_dict, 'discount-code': str(discount_code.id)} + { + 'tickets': tickets_dict, + 'discount-code': str(discount_code.id), + 'discount_verify': True, + } ), ) diff --git a/tests/all/integration/api/helpers/order/test_create_order.py b/tests/all/integration/api/helpers/order/test_create_order.py index 4a087313c7..f06bc1a155 100644 --- a/tests/all/integration/api/helpers/order/test_create_order.py +++ b/tests/all/integration/api/helpers/order/test_create_order.py @@ -29,7 +29,11 @@ def test_create_order(client, db, jwt): content_type='application/json', headers=jwt, data=json.dumps( - {'tickets': tickets_dict, 'discount-code': str(discount_code.id)} + { + 'tickets': tickets_dict, + 'discount-code': str(discount_code.id), + 'discount-verify': True, + } ), ) diff --git a/tests/all/integration/api/helpers/test_ticketing.py b/tests/all/integration/api/helpers/test_ticketing.py index 85ed10a018..7b6ba3f84a 100644 --- a/tests/all/integration/api/helpers/test_ticketing.py +++ b/tests/all/integration/api/helpers/test_ticketing.py @@ -1,3 +1,4 @@ +from app.api.helpers.errors import UnprocessableEntityError from tests.factories.attendee import AttendeeFactoryBase from tests.factories.discount_code import DiscountCodeTicketFactory from tests.factories.order import OrderFactory @@ -22,8 +23,15 @@ def test_match_discount_quantity(db): ticket_id=ticket.id, event_id=ticket.event_id, ) - - assert discount_code.is_available(ticket_holders=[1]) is True + quantity_discount: dict = { + 'numb_no_discount': 0, + 'numb_discount': 1, + } + + discount_quantity = discount_code.is_available(ticket_holders=[1]) + assert discount_quantity.get('numb_discount') == quantity_discount.get( + 'numb_discount' + ) order_with_discount = OrderFactory( status='completed', discount_code_id=discount_code.id @@ -33,8 +41,16 @@ def test_match_discount_quantity(db): # Attendees associated with the order with discount code should be counted AttendeeFactoryBase.create_batch( - 5, order_id=order_with_discount.id, ticket_id=ticket.id, event_id=ticket.event_id + 5, + order_id=order_with_discount.id, + ticket_id=ticket.id, + event_id=ticket.event_id, + is_discount_applied=True, ) - assert discount_code.is_available(ticket_holders=[1]) is False + try: + discount_code.is_available(ticket_holders=[1]) + except UnprocessableEntityError as e: + assert e.source['pointer'] == 'discount_sold_out' + assert discount_code.confirmed_attendees_count == 5 diff --git a/tests/factories/attendee.py b/tests/factories/attendee.py index c496cdce03..c23c7f17b6 100644 --- a/tests/factories/attendee.py +++ b/tests/factories/attendee.py @@ -24,6 +24,7 @@ class Meta: ticket_id = None order_id = None modified_at = common.date_ + is_discount_applied = True class AttendeeSubFactory(AttendeeFactoryBase):