Exemplo n.º 1
0
class OrderPayment(models.Model):
    """
    Order payment

    Stores additional data from the payment interface for analysis
    and accountability.
    """

    PENDING = 10
    PROCESSED = 20
    AUTHORIZED = 30

    STATUS_CHOICES = (
        (PENDING, _('pending')),
        (PROCESSED, _('processed')),
        (AUTHORIZED, _('authorized')),
    )

    order = models.ForeignKey(
        Order, verbose_name=_('order'), related_name='payments')
    timestamp = models.DateTimeField(_('timestamp'), default=timezone.now)
    status = models.PositiveIntegerField(
        _('status'), choices=STATUS_CHOICES, default=PENDING)

    currency = CurrencyField()
    amount = models.DecimalField(_('amount'), max_digits=10, decimal_places=2)
    payment_module_key = models.CharField(
        _('payment module key'),
        max_length=20,
        help_text=_(
            'Machine-readable identifier for the payment module used.'))
    payment_module = models.CharField(
        _('payment module'), max_length=50,
        blank=True,
        help_text=_('For example \'Cash on delivery\', \'PayPal\', ...'))
    payment_method = models.CharField(
        _('payment method'), max_length=50,
        blank=True,
        help_text=_(
            'For example \'MasterCard\', \'VISA\' or some other card.'))
    transaction_id = models.CharField(
        _('transaction ID'), max_length=50,
        blank=True,
        help_text=_(
            'Unique ID identifying this payment in the foreign system.'))

    authorized = models.DateTimeField(
        _('authorized'), blank=True, null=True,
        help_text=_('Point in time when payment has been authorized.'))

    notes = models.TextField(_('notes'), blank=True)

    data = JSONField(
        _('data'), blank=True,
        help_text=_('JSON-encoded additional data about the order payment.'))

    class Meta:
        ordering = ('-timestamp',)
        verbose_name = _('order payment')
        verbose_name_plural = _('order payments')

    objects = OrderPaymentManager()

    def __str__(self):
        return _(
            '%(authorized)s of %(currency)s %(amount).2f for %(order)s'
        ) % {
            'authorized': (
                self.authorized and _('Authorized') or _('Not authorized')),
            'currency': self.currency,
            'amount': self.amount,
            'order': self.order,
        }

    def _recalculate_paid(self):
        paid = OrderPayment.objects.authorized().filter(
            order=self.order_id,
            currency=F('order__currency'),
        ).aggregate(total=Sum('amount'))['total'] or 0

        Order.objects.filter(id=self.order_id).update(paid=paid)

    def save(self, *args, **kwargs):
        super(OrderPayment, self).save(*args, **kwargs)
        self._recalculate_paid()

        if self.currency != self.order.currency:
            self.order.notes += (
                u'\n' + _('Currency of payment %s does not match.') % self)
            self.order.save()
    save.alters_data = True

    def delete(self, *args, **kwargs):
        super(OrderPayment, self).delete(*args, **kwargs)
        self._recalculate_paid()
    delete.alters_data = True
Exemplo n.º 2
0
class DiscountBase(models.Model):
    """Base class for discounts and applied discounts"""

    AMOUNT_VOUCHER_EXCL_TAX = 10
    AMOUNT_VOUCHER_INCL_TAX = 20
    PERCENTAGE_VOUCHER = 30
    MEANS_OF_PAYMENT = 40

    TYPE_CHOICES = (
        (
            AMOUNT_VOUCHER_EXCL_TAX,
            _("amount voucher excl. tax (reduces total tax on order)"),
        ),
        (
            AMOUNT_VOUCHER_INCL_TAX,
            _("amount voucher incl. tax (reduces total tax on order)"),
        ),
        (PERCENTAGE_VOUCHER,
         _("percentage voucher (reduces total tax on order)")),
        (MEANS_OF_PAYMENT,
         _("means of payment (does not change total tax on order)")),
    )

    #: You can add and remove options at will, except for 'all': This option
    #: must always be available, and it cannot have any form fields
    CONFIG_OPTIONS = [
        ("all", {
            "title": _("All products")
        }),
        (
            "exclude_sale",
            {
                "title": _("Exclude sale prices"),
                "orderitem_query": lambda **values: Q(is_sale=False),
            },
        ),
    ]

    name = models.CharField(_("name"), max_length=100)

    type = models.PositiveIntegerField(_("type"), choices=TYPE_CHOICES)
    value = models.DecimalField(_("value"), max_digits=18, decimal_places=10)

    currency = CurrencyField(
        blank=True,
        null=True,
        help_text=_("Only required for amount discounts."))
    tax_class = models.ForeignKey(
        TaxClass,
        on_delete=models.CASCADE,
        verbose_name=_("tax class"),
        blank=True,
        null=True,
        help_text=_("Only required for amount discounts incl. tax."),
    )

    config = JSONField(
        _("configuration"),
        blank=True,
        help_text=_(
            "If you edit this field directly, changes below will be ignored."),
        default=dict,
    )

    class Meta:
        abstract = True

    def __str__(self):
        return self.name

    def save(self, *args, **kwargs):
        self.full_clean()
        super(DiscountBase, self).save(*args, **kwargs)

    save.alters_data = True

    def clean(self):
        if self.type == self.PERCENTAGE_VOUCHER:
            if self.currency or self.tax_class:
                raise ValidationError(
                    _("Percentage discounts cannot have currency and tax class set."
                      ))
        elif self.type == self.AMOUNT_VOUCHER_EXCL_TAX:
            if not self.currency:
                raise ValidationError(
                    _("Amount discounts excl. tax need a currency."))
            if self.tax_class:
                raise ValidationError(
                    _("Amount discounts excl. tax cannot have tax class set."))
        elif self.type == self.AMOUNT_VOUCHER_INCL_TAX:
            if not (self.currency and self.tax_class):
                raise ValidationError(
                    _("Amount discounts incl. tax need a currency and a tax class."
                      ))
        elif self.type == self.MEANS_OF_PAYMENT:
            if not self.currency:
                raise ValidationError(_("Means of payment need a currency."))
            if self.tax_class:
                raise ValidationError(
                    _("Means of payment cannot have tax class set."))
        else:
            raise ValidationError(_("Unknown discount type."))

    def _eligible_products(self, order, items):
        """
        Return a list of products which are eligible for discounting using
        the discount configuration.
        """

        product_model = plata.product_model()

        products = product_model._default_manager.filter(
            id__in=[item.product_id for item in items])
        orderitems = order.items.model._default_manager.filter(
            id__in=[item.id for item in items])

        for key, parameters in self.config.items():
            parameters = dict((str(k), v) for k, v in parameters.items())

            cfg = dict(self.CONFIG_OPTIONS)[key]

            if "product_query" in cfg:
                products = products.filter(cfg["product_query"](**parameters))
            if "orderitem_query" in cfg:
                orderitems = orderitems.filter(
                    cfg["orderitem_query"](**parameters))

        return products.filter(id__in=orderitems.values("product_id"))

    def apply(self, order, items, **kwargs):
        if not items:
            return

        if self.type == self.AMOUNT_VOUCHER_EXCL_TAX:
            self._apply_amount_discount(order, items, tax_included=False)
        elif self.type == self.AMOUNT_VOUCHER_INCL_TAX:
            self._apply_amount_discount(order, items, tax_included=True)
        elif self.type == self.PERCENTAGE_VOUCHER:
            self._apply_percentage_discount(order, items)
        elif self.type == self.MEANS_OF_PAYMENT:
            self._apply_means_of_payment(order, items)
        else:
            raise NotImplementedError("Unknown discount type %s" % self.type)

    def _apply_amount_discount(self, order, items, tax_included):
        """
        Apply amount discount evenly to all eligible order items

        Aggregates remaining discount (if discount is bigger than order total)
        """

        eligible_products = self._eligible_products(order, items).values_list(
            "id", flat=True)
        eligible_items = [
            item for item in items if item.product_id in eligible_products
        ]

        if tax_included:
            discount = self.value / (1 + self.tax_class.rate / 100)
        else:
            discount = self.value

        items_subtotal = sum(
            [item.discounted_subtotal_excl_tax for item in eligible_items],
            Decimal("0.00"),
        )

        # Don't allow bigger discounts than the items subtotal
        if discount > items_subtotal:
            self.remaining = (discount - items_subtotal).quantize(
                Decimal("0E-10"))
            self.save()
            discount = items_subtotal

        for item in eligible_items:
            item._line_item_discount += (item.discounted_subtotal_excl_tax /
                                         items_subtotal * discount)

    def _apply_means_of_payment(self, order, items):

        items_tax = sum((item._line_item_tax for item in items),
                        Decimal("0.00"))

        for item in items:
            items_tax

        discount = self.value
        # items_subtotal = order.subtotal if \
        #    order.price_includes_tax else order.subtotal + items_tax
        # CHECK: items_subtotal is unused!

        # Don't allow bigger discounts than the items subtotal
        remaining = discount
        for item in items:
            if order.price_includes_tax:
                items_subtotal_inkl_taxes = item.subtotal
                # items_subtotal_excl_taxes = item._unit_price * item.quantity
            else:
                items_subtotal_inkl_taxes = item.subtotal + item._line_item_tax
                # items_subtotal_excl_taxes = item.subtotal
            # CHECK: items_subtotal_excl_taxes is unused!

            if remaining >= items_subtotal_inkl_taxes - item._line_item_discount:
                if item._line_item_discount < items_subtotal_inkl_taxes:
                    new_discount = items_subtotal_inkl_taxes - item._line_item_discount
                    item._line_item_discount += new_discount
                    remaining -= new_discount
            else:
                item._line_item_discount += remaining
                remaining = 0

        self.remaining = remaining
        self.save()

    def _apply_percentage_discount(self, order, items):
        """
        Apply percentage discount evenly to all eligible order items
        """

        eligible_products = self._eligible_products(order, items).values_list(
            "id", flat=True)

        factor = self.value / 100

        for item in items:
            if item.product_id not in eligible_products:
                continue

            item._line_item_discount += item.discounted_subtotal_excl_tax * factor
Exemplo n.º 3
0
class Order(BillingShippingAddress):
    """The main order model. Used for carts and orders alike."""
    #: Order object is a cart.
    CART = 10
    #: Checkout process has started.
    CHECKOUT = 20
    #: Order has been confirmed, but it not (completely) paid for yet.
    CONFIRMED = 30
    #: Order has been completely paid for.
    PAID = 40
    #: Order has been completed. Plata itself never sets this state,
    #: it is only meant for use by the shop owners.
    COMPLETED = 50

    STATUS_CHOICES = (
        (CART, _('Is a cart')),
        (CHECKOUT, _('Checkout process started')),
        (CONFIRMED, _('Order has been confirmed')),
        (PAID, _('Order has been paid')),
        (COMPLETED, _('Order has been completed')),
        )

    created = models.DateTimeField(_('created'), default=timezone.now)
    confirmed = models.DateTimeField(_('confirmed'), blank=True, null=True)
    user = models.ForeignKey(
        getattr(settings, 'AUTH_USER_MODEL', 'auth.User'),
        blank=True,
        null=True,
        verbose_name=_('user'),
        related_name='orders'
    )
    language_code = models.CharField(
        _('language'), max_length=10, default='', blank=True)
    status = models.PositiveIntegerField(
        _('status'), choices=STATUS_CHOICES, default=CART)

    _order_id = models.CharField(_('order ID'), max_length=20, blank=True)
    email = models.EmailField(_('e-mail address'))

    currency = CurrencyField()
    price_includes_tax = models.BooleanField(
        _('price includes tax'),
        default=plata.settings.PLATA_PRICE_INCLUDES_TAX)

    items_subtotal = models.DecimalField(
        _('subtotal'),
        max_digits=18, decimal_places=10, default=Decimal('0.00'))
    items_discount = models.DecimalField(
        _('items discount'),
        max_digits=18, decimal_places=10, default=Decimal('0.00'))
    items_tax = models.DecimalField(
        _('items tax'),
        max_digits=18, decimal_places=10, default=Decimal('0.00'))

    shipping_method = models.CharField(
        _('shipping method'),
        max_length=100, blank=True)
    shipping_cost = models.DecimalField(
        _('shipping cost'),
        max_digits=18, decimal_places=10, blank=True, null=True)
    shipping_discount = models.DecimalField(
        _('shipping discount'),
        max_digits=18, decimal_places=10, blank=True, null=True)
    shipping_tax = models.DecimalField(
        _('shipping tax'),
        max_digits=18, decimal_places=10, default=Decimal('0.00'))

    total = models.DecimalField(
        _('total'),
        max_digits=18, decimal_places=10, default=Decimal('0.00'))

    paid = models.DecimalField(
        _('paid'),
        max_digits=18, decimal_places=10, default=Decimal('0.00'),
        help_text=_('This much has been paid already.'))

    notes = models.TextField(_('notes'), blank=True)

    data = JSONField(
        _('data'), blank=True,
        help_text=_('JSON-encoded additional data about the order payment.'))

    class Meta:
        verbose_name = _('order')
        verbose_name_plural = _('orders')

    def __str__(self):
        return self.order_id

    def save(self, *args, **kwargs):
        """Sequential order IDs for completed orders."""
        if not self._order_id and self.status >= self.PAID:
            try:
                order = Order.objects.exclude(_order_id='').order_by(
                    '-_order_id')[0]
                latest = int(re.sub(r'[^0-9]', '', order._order_id))
            except (IndexError, ValueError):
                latest = 0

            self._order_id = 'O-%09d' % (latest + 1)
        super(Order, self).save(*args, **kwargs)
    save.alters_data = True

    @property
    def order_id(self):
        """
        Returns ``_order_id`` (if it has been set) or a generic ID for this
        order.
        """
        if self._order_id:
            return self._order_id
        return u'No. %d' % self.id

    def recalculate_total(self, save=True):
        """
        Recalculates totals, discounts, taxes.
        """

        items = list(self.items.all())
        shared_state = {}

        processor_classes = [
            get_callable(processor)
            for processor in plata.settings.PLATA_ORDER_PROCESSORS]

        for p in (cls(shared_state) for cls in processor_classes):
            p.process(self, items)

        if save:
            self.save()
            [item.save() for item in items]

    @property
    def subtotal(self):
        """
        Returns the order subtotal.
        """
        # TODO: What about shipping?
        return sum(
            (item.subtotal for item in self.items.all()),
            Decimal('0.00')).quantize(Decimal('0.00'))

    @property
    def discount(self):
        """
        Returns the discount total.
        """
        # TODO: What about shipping?
        return (
            sum(
                (item.subtotal for item in self.items.all()),
                Decimal('0.00')
            ) - sum(
                (item.discounted_subtotal for item in self.items.all()),
                Decimal('0.00')
            )
        ).quantize(Decimal('0.00'))

    @property
    def shipping(self):
        """
        Returns the shipping cost, with or without tax depending on this
        order's ``price_includes_tax`` field.
        """
        if self.price_includes_tax:
            if self.shipping_cost is None:
                return None

            return (
                self.shipping_cost
                - self.shipping_discount
                + self.shipping_tax)
        else:
            logger.error(
                'Shipping calculation with'
                ' PLATA_PRICE_INCLUDES_TAX=False is not implemented yet')
            raise NotImplementedError

    @property
    def tax(self):
        """
        Returns the tax total for this order, meaning tax on order items and
        tax on shipping.
        """
        return (self.items_tax + self.shipping_tax).quantize(Decimal('0.00'))

    @property
    def balance_remaining(self):
        """
        Returns the balance which needs to be paid by the customer to fully
        pay this order. This value is not necessarily the same as the order
        total, because there can be more than one order payment in principle.
        """
        return (self.total - self.paid).quantize(Decimal('0.00'))

    def is_paid(self):
        import warnings
        warnings.warn(
            'Order.is_paid() has been deprecated because its name is'
            ' misleading. Test for `order.status >= order.PAID` or'
            ' `not order.balance_remaining yourself.',
            DeprecationWarning, stacklevel=2)
        return self.balance_remaining <= 0

    #: This validator is always called; basic consistency checks such as
    #: whether the currencies in the order match should be added here.
    VALIDATE_BASE = 10
    #: A cart which fails the criteria added to the ``VALIDATE_CART`` group
    #: isn't considered a valid cart and the user cannot proceed to the
    #: checkout form. Stuff such as stock checking, minimal order total
    #: checking, or maximal items checking might be added here.
    VALIDATE_CART = 20
    #: This should not be used while registering a validator, it's mostly
    #: useful as an argument to :meth:`~plata.shop.models.Order.validate`
    #: when you want to run all validators.
    VALIDATE_ALL = 100

    VALIDATORS = {}

    @classmethod
    def register_validator(cls, validator, group):
        """
        Registers another order validator in a validation group

        A validator is a callable accepting an order (and only an order).

        There are several types of order validators:

        - Base validators are always called
        - Cart validators: Need to validate for a valid cart
        - Checkout validators: Need to validate in the checkout process
        """

        cls.VALIDATORS.setdefault(group, []).append(validator)

    def validate(self, group):
        """
        Validates this order

        The argument determines which order validators are called:

        - ``Order.VALIDATE_BASE``
        - ``Order.VALIDATE_CART``
        - ``Order.VALIDATE_CHECKOUT``
        - ``Order.VALIDATE_ALL``
        """

        for g in sorted(g for g in self.VALIDATORS.keys() if g <= group):
            for validator in self.VALIDATORS[g]:
                validator(self)

    def is_confirmed(self):
        """
        Returns ``True`` if this order has already been confirmed and
        therefore cannot be modified anymore.
        """
        return self.status >= self.CONFIRMED

    def modify_item(self, product, relative=None, absolute=None,
                    recalculate=True, data=None, item=None, force_new=False):
        """
        Updates order with the given product

        - ``relative`` or ``absolute``: Add/subtract or define order item
          amount exactly
        - ``recalculate``: Recalculate order after cart modification
          (defaults to ``True``)
        - ``data``: Additional data for the order item; replaces the contents
          of the JSON field if it is not ``None``. Pass an empty dictionary
          if you want to reset the contents.
        - ``item``: The order item which should be modified. Will be
          automatically detected using the product if unspecified.
        - ``force_new``: Force the creation of a new order item, even if the
          product exists already in the cart (especially useful if the
          product is configurable).

        Returns the ``OrderItem`` instance; if quantity is zero, the order
        item instance is deleted, the ``pk`` attribute set to ``None`` but
        the order item is returned anyway.
        """

        assert (relative is None) != (absolute is None),\
            'One of relative or absolute must be provided.'
        assert not (force_new and item),\
            'Cannot set item and force_new at the same time.'

        if self.is_confirmed():
            raise ValidationError(
                _('Cannot modify order once it has been confirmed.'),
                code='order_sealed')

        if item is None and not force_new:
            try:
                item = self.items.get(product=product)
            except self.items.model.DoesNotExist:
                # Ok, product does not exist in cart yet.
                pass
            except self.items.model.MultipleObjectsReturned:
                # Oops. Product already exists several times. Stay on the
                # safe side and add a new one instead of trying to modify
                # another.
                if not force_new:
                    raise ValidationError(
                        _(
                            'The product already exists several times in the'
                            ' cart, and neither item nor force_new were'
                            ' given.'),
                        code='multiple')

        if item is None:
            item = self.items.model(
                order=self,
                product=product,
                quantity=0,
                currency=self.currency,
            )

        if relative is not None:
            item.quantity += relative
        else:
            item.quantity = absolute

        if item.quantity > 0:
            try:
                price = product.get_price(
                    currency=self.currency,
                    orderitem=item)
            except ObjectDoesNotExist:
                logger.error(
                    u'No price could be found for %s with currency %s' % (
                        product, self.currency))

                raise ValidationError(
                    _('The price could not be determined.'),
                    code='unknown_price')

            if data is not None:
                item.data = data

            price.handle_order_item(item)
            product.handle_order_item(item)
            item.save()
        else:
            if item.pk:
                item.delete()
                item.pk = None

        if recalculate:
            self.recalculate_total()

            # Reload item instance from DB to preserve field values
            # changed in recalculate_total
            if item.pk:
                item = self.items.get(pk=item.pk)

        try:
            self.validate(self.VALIDATE_BASE)
        except ValidationError:
            if item.pk:
                item.delete()
            raise

        return item

    @property
    def discount_remaining(self):
        """Remaining discount amount excl. tax"""
        return self.applied_discounts.remaining()

    def update_status(self, status, notes):
        """
        Update the order status
        """

        if status >= Order.CHECKOUT:
            if not self.items.count():
                raise ValidationError(
                    _('Cannot proceed to checkout without order items.'),
                    code='order_empty')

        logger.info('Promoting %s to status %s' % (self, status))

        instance = OrderStatus(
            order=self,
            status=status,
            notes=notes)
        instance.save()

    def reload(self):
        """
        Return this order instance, reloaded from the database

        Used f.e. inside the payment processors when adding new payment
        records etc.
        """

        return self.__class__._default_manager.get(pk=self.id)

    def items_in_order(self):
        """
        Returns the item count in the order

        This is different from ``order.items.count()`` because it counts items,
        not distinct products.
        """
        return self.items.aggregate(q=Sum('quantity'))['q'] or 0
Exemplo n.º 4
0
class OrderItem(models.Model):
    """Single order line item"""

    order = models.ForeignKey(Order, related_name='items')
    product = models.ForeignKey(
        plata.settings.PLATA_SHOP_PRODUCT,
        verbose_name=_('product'),
        blank=True, null=True, on_delete=models.SET_NULL)

    name = models.CharField(_('name'), max_length=100, blank=True)
    sku = models.CharField(_('SKU'), max_length=100, blank=True)

    quantity = models.IntegerField(_('quantity'))

    currency = CurrencyField()
    _unit_price = models.DecimalField(
        _('unit price'),
        max_digits=18, decimal_places=10,
        help_text=_('Unit price excl. tax'))
    _unit_tax = models.DecimalField(
        _('unit tax'),
        max_digits=18, decimal_places=10)

    tax_rate = models.DecimalField(
        _('tax rate'),
        max_digits=10, decimal_places=2)
    tax_class = models.ForeignKey(
        TaxClass, verbose_name=_('tax class'),
        blank=True, null=True, on_delete=models.SET_NULL)

    is_sale = models.BooleanField(_('is sale'))

    _line_item_price = models.DecimalField(
        _('line item price'),
        max_digits=18, decimal_places=10, default=0,
        help_text=_('Line item price excl. tax'))
    _line_item_discount = models.DecimalField(
        _('line item discount'),
        max_digits=18, decimal_places=10,
        blank=True, null=True,
        help_text=_('Discount excl. tax'))

    _line_item_tax = models.DecimalField(
        _('line item tax'),
        max_digits=18, decimal_places=10, default=0)

    data = JSONField(
        _('data'), blank=True,
        help_text=_('JSON-encoded additional data about the order payment.'))

    class Meta:
        ordering = ('product',)
        verbose_name = _('order item')
        verbose_name_plural = _('order items')

    def __str__(self):
        return _('%(quantity)s of %(name)s') % {
            'quantity': self.quantity,
            'name': self.name,
        }

    @property
    def unit_price(self):
        if self.order.price_includes_tax:
            return self._unit_price + self._unit_tax
        return self._unit_price

    @property
    def line_item_discount_excl_tax(self):
        return self._line_item_discount or 0

    @property
    def line_item_discount_incl_tax(self):
        return self.line_item_discount_excl_tax * (1 + self.tax_rate / 100)

    @property
    def line_item_discount(self):
        if self.order.price_includes_tax:
            return self.line_item_discount_incl_tax
        else:
            return self.line_item_discount_excl_tax

    @property
    def subtotal(self):
        return self.unit_price * self.quantity

    @property
    def discounted_subtotal_excl_tax(self):
        return self._line_item_price - (self._line_item_discount or 0)

    @property
    def discounted_subtotal_incl_tax(self):
        return self.discounted_subtotal_excl_tax + self._line_item_tax

    @property
    def discounted_subtotal(self):
        if self.order.price_includes_tax:
            return self.discounted_subtotal_incl_tax
        else:
            return self.discounted_subtotal_excl_tax
Exemplo n.º 5
0
class DiscountBase(models.Model):
    """Base class for discounts and applied discounts"""

    AMOUNT_VOUCHER_EXCL_TAX = 10
    AMOUNT_VOUCHER_INCL_TAX = 20
    PERCENTAGE_VOUCHER = 30
    MEANS_OF_PAYMENT = 40

    TYPE_CHOICES = (
        (AMOUNT_VOUCHER_EXCL_TAX,
         _('amount voucher excl. tax (reduces total tax on order)')),
        (AMOUNT_VOUCHER_INCL_TAX,
         _('amount voucher incl. tax (reduces total tax on order)')),
        (PERCENTAGE_VOUCHER,
         _('percentage voucher (reduces total tax on order)')),
        (MEANS_OF_PAYMENT,
         _('means of payment (does not change total tax on order)')),
    )

    #: You can add and remove options at will, except for 'all': This option
    #: must always be available, and it cannot have any form fields
    CONFIG_OPTIONS = [
        ('all', {
            'title': _('All products'),
        }),
        ('exclude_sale', {
            'title': _('Exclude sale prices'),
            'orderitem_query': lambda **values: Q(is_sale=False),
        }),
    ]

    name = models.CharField(_('name'), max_length=100)

    type = models.PositiveIntegerField(_('type'), choices=TYPE_CHOICES)
    value = models.DecimalField(_('value'), max_digits=18, decimal_places=10)

    currency = CurrencyField(
        blank=True,
        null=True,
        help_text=_('Only required for amount discounts.'))
    tax_class = models.ForeignKey(
        TaxClass,
        verbose_name=_('tax class'),
        blank=True,
        null=True,
        help_text=_('Only required for amount discounts incl. tax.'))

    config = JSONField(
        _('configuration'),
        blank=True,
        help_text=_('If you edit this field directly, changes below will be'
                    ' ignored.'))

    class Meta:
        abstract = True

    def __str__(self):
        return self.name

    def save(self, *args, **kwargs):
        self.full_clean()
        super(DiscountBase, self).save(*args, **kwargs)

    save.alters_data = True

    def clean(self):
        if self.type == self.PERCENTAGE_VOUCHER:
            if self.currency or self.tax_class:
                raise ValidationError(
                    _('Percentage discounts cannot have currency and tax'
                      ' class set.'))
        elif self.type == self.AMOUNT_VOUCHER_EXCL_TAX:
            if not self.currency:
                raise ValidationError(
                    _('Amount discounts excl. tax need a currency.'))
            if self.tax_class:
                raise ValidationError(
                    _('Amount discounts excl. tax cannot have tax class'
                      ' set.'))
        elif self.type == self.AMOUNT_VOUCHER_INCL_TAX:
            if not (self.currency and self.tax_class):
                raise ValidationError(
                    _('Amount discounts incl. tax need a currency and a tax'
                      ' class.'))
        elif self.type == self.MEANS_OF_PAYMENT:
            if not self.currency:
                raise ValidationError(_('Means of payment need a currency.'))
            if self.tax_class:
                raise ValidationError(
                    _('Means of payment cannot have tax class set.'))
        else:
            raise ValidationError(_('Unknown discount type.'))

    def _eligible_products(self, order, items):
        """
        Return a list of products which are eligible for discounting using
        the discount configuration.
        """

        product_model = plata.product_model()

        products = product_model._default_manager.filter(
            id__in=[item.product_id for item in items])
        orderitems = order.items.model._default_manager.filter(
            id__in=[item.id for item in items])

        for key, parameters in self.config.items():
            parameters = dict((str(k), v) for k, v in parameters.items())

            cfg = dict(self.CONFIG_OPTIONS)[key]

            if 'product_query' in cfg:
                products = products.filter(cfg['product_query'](**parameters))
            if 'orderitem_query' in cfg:
                orderitems = orderitems.filter(
                    cfg['orderitem_query'](**parameters))

        return products.filter(id__in=orderitems.values('product_id'))

    def apply(self, order, items, **kwargs):
        if not items:
            return

        if self.type == self.AMOUNT_VOUCHER_EXCL_TAX:
            self._apply_amount_discount(order, items, tax_included=False)
        elif self.type == self.AMOUNT_VOUCHER_INCL_TAX:
            self._apply_amount_discount(order, items, tax_included=True)
        elif self.type == self.PERCENTAGE_VOUCHER:
            self._apply_percentage_discount(order, items)
        elif self.type == self.MEANS_OF_PAYMENT:
            self._apply_means_of_payment(order, items)
        else:
            raise NotImplementedError('Unknown discount type %s' % self.type)

    def _apply_amount_discount(self, order, items, tax_included):
        """
        Apply amount discount evenly to all eligible order items

        Aggregates remaining discount (if discount is bigger than order total)
        """

        eligible_products = self._eligible_products(order, items).values_list(
            'id', flat=True)
        eligible_items = [
            item for item in items if item.product_id in eligible_products
        ]

        if tax_included:
            discount = self.value / (1 + self.tax_class.rate / 100)
        else:
            discount = self.value

        items_subtotal = sum(
            [item.discounted_subtotal_excl_tax for item in eligible_items],
            Decimal('0.00'))

        # Don't allow bigger discounts than the items subtotal
        if discount > items_subtotal:
            self.remaining = discount - items_subtotal
            self.save()
            discount = items_subtotal

        for item in eligible_items:
            item._line_item_discount += (item.discounted_subtotal_excl_tax /
                                         items_subtotal * discount)

    def _apply_means_of_payment(self, order, items):
        self._apply_amount_discount(order, items, tax_included=False)

    def _apply_percentage_discount(self, order, items):
        """
        Apply percentage discount evenly to all eligible order items
        """

        eligible_products = self._eligible_products(order, items).values_list(
            'id', flat=True)

        factor = self.value / 100

        for item in items:
            if item.product_id not in eligible_products:
                continue

            item._line_item_discount += (item.discounted_subtotal_excl_tax *
                                         factor)
Exemplo n.º 6
0
class OrderPayment(models.Model):
    """
    Order payment

    Stores additional data from the payment interface for analysis
    and accountability.
    """

    PENDING = 10
    PROCESSED = 20
    AUTHORIZED = 30

    STATUS_CHOICES = (
        (PENDING, _("pending")),
        (PROCESSED, _("processed")),
        (AUTHORIZED, _("authorized")),
    )

    order = models.ForeignKey(
        Order,
        on_delete=models.CASCADE,
        verbose_name=_("order"),
        related_name="payments",
    )
    timestamp = models.DateTimeField(_("timestamp"), default=timezone.now)
    status = models.PositiveIntegerField(_("status"),
                                         choices=STATUS_CHOICES,
                                         default=PENDING)

    currency = CurrencyField()
    amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2)
    payment_module_key = models.CharField(
        _("payment module key"),
        max_length=20,
        help_text=_(
            "Machine-readable identifier for the payment module used."),
    )
    payment_module = models.CharField(
        _("payment module"),
        max_length=50,
        blank=True,
        help_text=_("For example 'Cash on delivery', 'PayPal', ..."),
    )
    payment_method = models.CharField(
        _("payment method"),
        max_length=50,
        blank=True,
        help_text=_("For example 'MasterCard', 'VISA' or some other card."),
    )
    transaction_id = models.CharField(
        _("transaction ID"),
        max_length=50,
        blank=True,
        help_text=_(
            "Unique ID identifying this payment in the foreign system."),
    )

    authorized = models.DateTimeField(
        _("authorized"),
        blank=True,
        null=True,
        help_text=_("Point in time when payment has been authorized."),
    )

    notes = models.TextField(_("notes"), blank=True)

    data = JSONField(
        _("data"),
        blank=True,
        help_text=_("JSON-encoded additional data about the order payment."),
        default=dict,
    )

    transaction_fee = models.DecimalField(
        _("transaction fee"),
        max_digits=10,
        decimal_places=2,
        null=True,
        blank=True,
        help_text=_("Fee charged by the payment processor."),
    )

    class Meta:
        ordering = ("-timestamp", )
        verbose_name = _("order payment")
        verbose_name_plural = _("order payments")

    objects = OrderPaymentManager()

    def __str__(self):
        return _(
            "%(authorized)s of %(currency)s %(amount).2f for %(order)s") % {
                "authorized":
                (self.authorized and _("Authorized") or _("Not authorized")),
                "currency":
                self.currency,
                "amount":
                self.amount,
                "order":
                self.order,
            }

    def _recalculate_paid(self):
        paid = (OrderPayment.objects.authorized().filter(
            order=self.order_id, currency=F("order__currency")).aggregate(
                total=Sum("amount"))["total"] or 0)

        Order.objects.filter(id=self.order_id).update(paid=paid)

    def save(self, *args, **kwargs):
        super(OrderPayment, self).save(*args, **kwargs)
        self._recalculate_paid()

        if self.currency != self.order.currency:
            self.order.notes += (
                "\n" + _("Currency of payment %s does not match.") % self)
            self.order.save()

    save.alters_data = True

    def delete(self, *args, **kwargs):
        super(OrderPayment, self).delete(*args, **kwargs)
        self._recalculate_paid()

    delete.alters_data = True
Exemplo n.º 7
0
class OrderItem(models.Model):
    """Single order line item"""

    order = models.ForeignKey(Order,
                              on_delete=models.CASCADE,
                              related_name="items")
    product = models.ForeignKey(
        plata.settings.PLATA_SHOP_PRODUCT,
        verbose_name=_("product"),
        blank=True,
        null=True,
        on_delete=models.SET_NULL,
    )

    name = models.CharField(_("name"), max_length=100, blank=True)
    sku = models.CharField(_("SKU"), max_length=100, blank=True)

    quantity = models.IntegerField(_("quantity"))

    currency = CurrencyField()
    _unit_price = models.DecimalField(
        _("unit price"),
        max_digits=18,
        decimal_places=10,
        help_text=_("Unit price excl. tax"),
    )
    _unit_tax = models.DecimalField(_("unit tax"),
                                    max_digits=18,
                                    decimal_places=10)

    tax_rate = models.DecimalField(_("tax rate"),
                                   max_digits=10,
                                   decimal_places=2)
    tax_class = models.ForeignKey(
        TaxClass,
        verbose_name=_("tax class"),
        blank=True,
        null=True,
        on_delete=models.SET_NULL,
    )

    is_sale = models.BooleanField(_("is sale"), default=False)

    _line_item_price = models.DecimalField(
        _("line item price"),
        max_digits=18,
        decimal_places=10,
        default=0,
        help_text=_("Line item price excl. tax"),
    )
    _line_item_discount = models.DecimalField(
        _("line item discount"),
        max_digits=18,
        decimal_places=10,
        blank=True,
        null=True,
        help_text=_("Discount excl. tax"),
    )

    _line_item_tax = models.DecimalField(_("line item tax"),
                                         max_digits=18,
                                         decimal_places=10,
                                         default=0)

    data = JSONField(
        _("data"),
        blank=True,
        help_text=_("JSON-encoded additional data about the order payment."),
        default=dict,
    )

    class Meta:
        ordering = ("product", )
        verbose_name = _("order item")
        verbose_name_plural = _("order items")

    def __str__(self):
        return _("%(quantity)s of %(name)s") % {
            "quantity": self.quantity,
            "name": self.name,
        }

    @property
    def unit_price(self):
        if self.order.price_includes_tax:
            return self._unit_price + self._unit_tax
        return self._unit_price

    @property
    def line_item_discount_excl_tax(self):
        return self._line_item_discount or 0

    @property
    def line_item_discount_incl_tax(self):
        return self.line_item_discount_excl_tax * (1 + self.tax_rate / 100)

    @property
    def line_item_discount(self):
        if self.order.price_includes_tax:
            return self.line_item_discount_incl_tax
        else:
            return self.line_item_discount_excl_tax

    @property
    def subtotal(self):
        return self.unit_price * self.quantity

    @property
    def discounted_subtotal_excl_tax(self):
        return self._line_item_price - (self._line_item_discount or 0)

    @property
    def discounted_subtotal_incl_tax(self):
        return self.discounted_subtotal_excl_tax + self._line_item_tax

    @property
    def discounted_subtotal(self):
        if self.order.price_includes_tax:
            return self.discounted_subtotal_incl_tax
        else:
            return self.discounted_subtotal_excl_tax
Exemplo n.º 8
0
class ChartQuery(models.Model):
    COUNT_INCOME = 0
    COUNT_ORDERS = 1

    name = models.CharField(max_length=255)
    uuid = models.CharField(max_length=255, default=generateUUID)
    query_json = JSONField()
    start_date = models.DateField(blank=True, null=True)
    end_date = models.DateField(blank=True, null=True)
    step = models.IntegerField(choices=((0, 'Yearly'), (1, 'Monthly'),
                                        (2, 'Weekly'), (3, 'Daily')),
                               default=1)
    renderer = models.CharField(choices=(('chartjs', 'Chart.js'),
                                         ('canvasjs', 'CanvasJS'), ('jqplot',
                                                                    'jqPlot')),
                                max_length=255,
                                default='canvasjs')
    count_type = models.IntegerField(choices=((COUNT_INCOME, 'Income'),
                                              (COUNT_ORDERS,
                                               'Number of orders')),
                                     default=0)
    currency = CurrencyField(default="EUR")

    def __unicode__(self):
        return self.name

    @property
    def start_date_iso(self):
        """
        Return a offset-aware date in iso format
        """
        if self.start_date:
            return datetime(self.start_date.year,
                            self.start_date.month,
                            self.start_date.day,
                            tzinfo=timezone.UTC()).isoformat()
        return ""

    @property
    def end_date_iso(self):
        """
        Return a offset-aware date in iso format
        """
        if self.end_date:
            return datetime(self.end_date.year,
                            self.end_date.month,
                            self.end_date.day,
                            tzinfo=timezone.UTC()).isoformat()
        return ""

    def invalidate_cache(self):
        try:
            ChartCache.objects.get(uuid=self.uuid, step=self.step).delete()
            logger.debug("Chart cache deleted")
        except ChartCache.DoesNotExist:
            pass
        return True

    @property
    def invalidate_cache_fields(self):
        """
        Return the list of fields that requires to remove the chart cache
        when changed.
        """
        return ('query_json', 'currency')

    def save(self, *args, **kwargs):
        """
        Check if the chart cache must be removed before updating
        the DB.
        """
        if self.pk is not None:
            orig = ChartQuery.objects.get(pk=self.pk)
            for field in self.invalidate_cache_fields:
                if getattr(self, field) != getattr(orig, field):
                    self.invalidate_cache()
                    # Handle the currency directly in the filter
                    if field == "currency":
                        self.query_json['order__currency'] = self.currency
        return models.Model.save(self, *args, **kwargs)

    class Meta:
        verbose_name = "Chart Query"
        verbose_name_plural = "Chart Queries"
Exemplo n.º 9
0
class ChartCache(models.Model):
    uuid = models.CharField(max_length=255)
    step = models.IntegerField(choices=((0, 'Yearly'), (1, 'Monthly'),
                                        (2, 'Weekly'), (3, 'Daily')),
                               default=1)
    cache = JSONField()