class Contact(BillingShippingAddress): """ Each user can have at most one of these Note: You do not have to use this model if you want to store the contact information somewhere else. If you use your own contact model, you should take care of two things: - ``Contact.update_from_order`` has to exist, and should fill in contact details from the order - You probably have to override ``Shop.checkout_form`` too - this method probably won't work for your custom contact model """ user = models.OneToOneField( getattr(settings, "AUTH_USER_MODEL", "auth.User"), on_delete=models.CASCADE, verbose_name=_("user"), related_name="contactuser", ) dob = models.DateField(_("date of birth"), blank=True, null=True) created = models.DateTimeField(_("created"), default=timezone.now) currency = CurrencyField(help_text=_("Preferred currency.")) notes = models.TextField(_("notes"), blank=True) class Meta: verbose_name = _("contact") verbose_name_plural = _("contacts") def __str__(self): return "%s" % self.user def update_from_order(self, order, request=None): """ This method is called by the checkout step and is used to update the contact information from an order instance """ self.currency = order.currency self.shipping_same_as_billing = order.shipping_same_as_billing for field in self.ADDRESS_FIELDS: f = "shipping_" + field setattr(self, f, getattr(order, f)) f = "billing_" + field setattr(self, f, getattr(order, f))
class Contact(BillingShippingAddress): """ Each user can have at most one of these Note: You do not have to use this model if you want to store the contact information somewhere else. If you use your own contact model, you should take care of two things: - ``Contact.update_from_order`` has to exist, and should fill in contact details from the order - You probably have to override ``Shop.checkout_form`` too - this method probably won't work for your custom contact model """ user = models.OneToOneField(User, verbose_name=_('user'), related_name='contactuser') dob = models.DateField(_('date of birth'), blank=True, null=True) created = models.DateTimeField(_('created'), default=datetime.now) currency = CurrencyField(help_text=_('Preferred currency.')) notes = models.TextField(_('notes'), blank=True) class Meta: verbose_name = _('contact') verbose_name_plural = _('contacts') def __unicode__(self): return unicode(self.user) def update_from_order(self, order, request=None): """ This method is called by the checkout step and is used to update the contact information from an order instance """ self.currency = order.currency self.shipping_same_as_billing = order.shipping_same_as_billing for field in self.ADDRESS_FIELDS: f = 'shipping_' + field setattr(self, f, getattr(order, f)) f = 'billing_' + field setattr(self, f, getattr(order, f))
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
class PriceBase(models.Model): """ Price for a given product, currency, tax class and time period Prices should not be changed or deleted but replaced by more recent prices. (Deleting old prices does not hurt, but the price history cannot be reconstructed anymore if you'd need it.) The concrete implementation needs to provide a foreign key to the product model. """ class Meta: abstract = True ordering = ['-id'] verbose_name = _('price') verbose_name_plural = _('prices') currency = CurrencyField() _unit_price = models.DecimalField( _('unit price'), max_digits=18, decimal_places=10) tax_included = models.BooleanField( _('tax included'), help_text=_('Is tax included in given unit price?'), default=plata.settings.PLATA_PRICE_INCLUDES_TAX) tax_class = models.ForeignKey( TaxClass, verbose_name=_('tax class'), related_name='+') def __str__(self): return '%s %.2f' % (self.currency, self.unit_price) def __cmp__(self, other): return int( (self.unit_price_excl_tax - other.unit_price_excl_tax) * 100) def __hash__(self): return int(self.unit_price_excl_tax * 100) def handle_order_item(self, item): """ Set price data on the ``OrderItem`` passed """ item._unit_price = self.unit_price_excl_tax item._unit_tax = self.unit_tax item.tax_rate = self.tax_class.rate item.tax_class = self.tax_class item.is_sale = False # Hardcoded; override in your own price class @property def unit_tax(self): return self.unit_price_excl_tax * (self.tax_class.rate / 100) @property def unit_price_incl_tax(self): if self.tax_included: return self._unit_price return self._unit_price * (1 + self.tax_class.rate / 100) @property def unit_price_excl_tax(self): if not self.tax_included: return self._unit_price return self._unit_price / (1 + self.tax_class.rate / 100) @property def unit_price(self): # TODO Fix this. We _should_ use shop.price_includes_tax here, # but there's no request and no order around... return self.unit_price_incl_tax
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
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
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
class Postage(models.Model): """ One class of shipping postage, e.g. letter or parcel. """ name = models.CharField( verbose_name=_("name"), max_length=31, help_text= _('How your shipping provider calls this class of packet, e.g. "Parcel XL".' ), ) provider = models.ForeignKey(ShippingProvider, on_delete=models.CASCADE, verbose_name=_("shipping provider")) country_group = models.ForeignKey( CountryGroup, on_delete=models.CASCADE, verbose_name=_("country group"), help_text=_("The tariff is valid for this group of countries."), ) price_internal = models.DecimalField( verbose_name=_("internal price"), max_digits=10, decimal_places=2, help_text=_("The price that the provider charges you."), ) price_packaging = models.DecimalField( verbose_name=_("packaging price"), max_digits=10, decimal_places=2, # default=plata.settings.PLATA_DEFAULT_PACKAGING_PRICE, help_text=_("What the packaging for a packet of this size costs you."), ) price_external = models.DecimalField( verbose_name=_("external price"), max_digits=10, decimal_places=2, help_text=_("The price that you charge your customers," " e.g. internal price plus packaging."), ) currency = CurrencyField( # verbose_name=_('currency'), help_text=_("Currency for all of these prices.")) price_includes_tax = models.BooleanField( verbose_name=_("price includes tax"), default=plata.settings.PLATA_PRICE_INCLUDES_TAX, ) weight_packaging = models.PositiveIntegerField( verbose_name=_("weight of packaging [%s]" % WEIGHT_UNIT), default=0, help_text=_( "The approx. weight of the necessary packaging for this package"), ) max_weight = models.PositiveIntegerField( verbose_name=_("max. weight [%s]" % WEIGHT_UNIT), default=0, help_text=_("Maximum weight for this tariff. 0 = ignored"), ) max_length = models.PositiveIntegerField( verbose_name=_("max. length [%s]" % LENGTH_UNIT), default=0, help_text=_("Maximum length for this tariff. 0 = ignored"), ) max_width = models.PositiveIntegerField( verbose_name=_("max. width [%s]" % LENGTH_UNIT), default=0, help_text=_("Maximum width for this tariff. 0 = ignored"), ) max_height = models.PositiveIntegerField( verbose_name=_("max. height [%s]" % LENGTH_UNIT), default=0, help_text=_("Maximum height for this tariff. 0 = ignored"), ) max_3d = models.PositiveIntegerField( verbose_name=_("max. dimensions [%s]" % LENGTH_UNIT), default=0, help_text= _("Maximum measure of length+width+height for this tariff. 0 = ignored" ), ) class Meta: verbose_name = _("postage") verbose_name_plural = _("postage classes") def __str__(self): return "%s: %s" % (self.provider.name, self.name) __str__.short_description = _("name") def max_weight_f(self): """ maximum weight with unit """ return "%d %s" % (self.max_weight, WEIGHT_UNIT) max_weight_f.help_text = _("maximum weight, formatted with unit") max_weight_f.short_description = _("max. weight") max_weight_f.verbose_name = _("max. weight") def max_size(self): """ maximum size of length + width + height, unformatted """ size = 0 if self.max_length and self.max_width and self.max_height: size = self.max_length + self.max_width + self.max_height if self.max_3d: return min(self.max_3d, size) elif self.max_3d: size = self.max_3d return size max_size.help_text = _("maximum size of length + width + height") max_size.short_description = _("max. size [%s]" % LENGTH_UNIT) max_size.verbose_name = _("max. size [%s]" % LENGTH_UNIT) def max_size_f(self): """ maximum size of length + width + height, formatted """ size = 0 d3 = False if self.max_length and self.max_width and self.max_height: size = self.max_length + self.max_width + self.max_height d3 = True if self.max_3d and self.max_3d < size: return "%d %s" % (self.max_3d, LENGTH_UNIT) if d3: return "%s × %s × %s %s" % ( self.max_length, self.max_width, self.max_height, LENGTH_UNIT, ) return "%d %s" % (size, LENGTH_UNIT) max_size_f.help_text = _( "maximum size of length + width + height, formatted with unit") max_size_f.short_description = _("max. size") max_size_f.verbose_name = _("max. size")
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)
class PriceBase(models.Model): """ Price for a given product, currency, tax class and time period Prices should not be changed or deleted but replaced by more recent prices. (Deleting old prices does not hurt, but the price history cannot be reconstructed anymore if you'd need it.) The concrete implementation needs to provide a foreign key to the product model. """ class Meta: abstract = True ordering = ["-id"] verbose_name = _("price") verbose_name_plural = _("prices") currency = CurrencyField() _unit_price = models.DecimalField(_("unit price"), max_digits=18, decimal_places=10) tax_included = models.BooleanField( _("tax included"), help_text=_("Is tax included in given unit price?"), default=plata.settings.PLATA_PRICE_INCLUDES_TAX, ) tax_class = models.ForeignKey( TaxClass, on_delete=models.CASCADE, verbose_name=_("tax class"), related_name="+", ) def __str__(self): return _("%(currency)s %(value).2f") % { "currency": self.currency, "value": self._unit_price, } def __cmp__(self, other): return int( (self.unit_price_excl_tax - other.unit_price_excl_tax) * 100) def __hash__(self): return int(self.unit_price_excl_tax * 100) def handle_order_item(self, item): """ Set price data on the ``OrderItem`` passed """ item._unit_price = self.unit_price_excl_tax item._unit_tax = self.unit_tax item.tax_rate = self.tax_class.rate item.tax_class = self.tax_class @property def unit_tax(self): return self.unit_price_excl_tax * (self.tax_class.rate / 100) @property def unit_price_incl_tax(self): if self.tax_included: return self._unit_price return self._unit_price * (1 + self.tax_class.rate / 100) @property def unit_price_excl_tax(self): if not self.tax_included: return self._unit_price return self._unit_price / (1 + self.tax_class.rate / 100) @property def unit_price(self): # TODO Fix this. We _should_ use shop.price_includes_tax here, # but there's no request and no order around... return self.unit_price_incl_tax
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
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
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"