diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py
index 6b103ff7..74b6a21a 100644
--- a/core/management/commands/populate.py
+++ b/core/management/commands/populate.py
@@ -50,7 +50,7 @@ from com.ics_calendar import IcsCalendar
 from com.models import News, NewsDate, Sith, Weekmail
 from core.models import BanGroup, Group, Page, PageRev, SithFile, User
 from core.utils import resize_image
-from counter.models import Counter, Product, ProductType, StudentCard
+from counter.models import Counter, Product, ProductType, ReturnableProduct, StudentCard
 from election.models import Candidature, Election, ElectionList, Role
 from forum.models import Forum
 from pedagogy.models import UV
@@ -451,7 +451,6 @@ Welcome to the wiki page!
             limit_age=18,
         )
         cons = Product.objects.create(
-            id=settings.SITH_ECOCUP_CONS,
             name="Consigne Eco-cup",
             code="CONS",
             product_type=verre,
@@ -461,7 +460,6 @@ Welcome to the wiki page!
             club=main_club,
         )
         dcons = Product.objects.create(
-            id=settings.SITH_ECOCUP_DECO,
             name="Déconsigne Eco-cup",
             code="DECO",
             product_type=verre,
@@ -510,6 +508,9 @@ Welcome to the wiki page!
             special_selling_price="0",
             club=refound,
         )
+        ReturnableProduct.objects.create(
+            product=cons, returned_product=dcons, max_return=3
+        )
 
         # Accounting test values:
         BankAccount.objects.create(name="AE TG", club=main_club)
diff --git a/core/static/core/components/card.scss b/core/static/core/components/card.scss
index c8e59098..941b32a5 100644
--- a/core/static/core/components/card.scss
+++ b/core/static/core/components/card.scss
@@ -55,6 +55,14 @@
     width: 80%;
   }
 
+  .card-top-left {
+    position: absolute;
+    top: 10px;
+    right: 10px;
+    padding: 10px;
+    text-align: center;
+  }
+
   .card-content {
     color: black;
     display: flex;
diff --git a/core/templates/core/delete_confirm.jinja b/core/templates/core/delete_confirm.jinja
index 3a393b67..6ae8a1b2 100644
--- a/core/templates/core/delete_confirm.jinja
+++ b/core/templates/core/delete_confirm.jinja
@@ -10,10 +10,17 @@
 {% block nav %}
 {% endblock %}
 
+{# if the template context has the `object_name` variable,
+   then this one will be used,
+   instead of the result of `str(object)` #}
+{% if object and not object_name %}
+  {% set object_name=object %}
+{% endif %}
+
 {% block content %}
   <h2>{% trans %}Delete confirmation{% endtrans %}</h2>
   <form action="" method="post">{% csrf_token %}
-    <p>{% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}</p>
+    <p>{% trans name=object_name %}Are you sure you want to delete "{{ name }}"?{% endtrans %}</p>
     <input type="submit" value="{% trans %}Confirm{% endtrans %}" />
   </form>
   <form method="GET" action="javascript:history.back();">
diff --git a/core/templates/core/user_tools.jinja b/core/templates/core/user_tools.jinja
index 4c9b9462..e1e8e581 100644
--- a/core/templates/core/user_tools.jinja
+++ b/core/templates/core/user_tools.jinja
@@ -62,6 +62,11 @@
                   {% trans %}Product types management{% endtrans %}
                 </a>
               </li>
+              <li>
+                <a href="{{ url("counter:returnable_list") }}">
+                  {% trans %}Returnable products management{% endtrans %}
+                </a>
+              </li>
               <li>
                 <a href="{{ url('counter:cash_summary_list') }}">
                   {% trans %}Cash register summaries{% endtrans %}
diff --git a/core/views/mixins.py b/core/views/mixins.py
index 9687f5d9..e5b445d6 100644
--- a/core/views/mixins.py
+++ b/core/views/mixins.py
@@ -1,3 +1,5 @@
+from typing import ClassVar
+
 from django.conf import settings
 from django.core.exceptions import ImproperlyConfigured
 from django.views import View
@@ -6,20 +8,24 @@ from django.views import View
 class TabedViewMixin(View):
     """Basic functions for displaying tabs in the template."""
 
+    current_tab: ClassVar[str | None] = None
+    list_of_tabs: ClassVar[list | None] = None
+    tabs_title: ClassVar[str | None] = None
+
     def get_tabs_title(self):
-        if hasattr(self, "tabs_title"):
-            return self.tabs_title
-        raise ImproperlyConfigured("tabs_title is required")
+        if not self.tabs_title:
+            raise ImproperlyConfigured("tabs_title is required")
+        return self.tabs_title
 
     def get_current_tab(self):
-        if hasattr(self, "current_tab"):
-            return self.current_tab
-        raise ImproperlyConfigured("current_tab is required")
+        if not self.current_tab:
+            raise ImproperlyConfigured("current_tab is required")
+        return self.current_tab
 
     def get_list_of_tabs(self):
-        if hasattr(self, "list_of_tabs"):
-            return self.list_of_tabs
-        raise ImproperlyConfigured("list_of_tabs is required")
+        if not self.list_of_tabs:
+            raise ImproperlyConfigured("list_of_tabs is required")
+        return self.list_of_tabs
 
     def get_context_data(self, **kwargs):
         kwargs = super().get_context_data(**kwargs)
diff --git a/core/views/user.py b/core/views/user.py
index 6b949c06..21958472 100644
--- a/core/views/user.py
+++ b/core/views/user.py
@@ -28,7 +28,6 @@ from datetime import date, timedelta
 from operator import itemgetter
 from smtplib import SMTPException
 
-from django.conf import settings
 from django.contrib.auth import login, views
 from django.contrib.auth.forms import PasswordChangeForm
 from django.contrib.auth.mixins import LoginRequiredMixin
diff --git a/counter/admin.py b/counter/admin.py
index 5dc795f2..10f04c8d 100644
--- a/counter/admin.py
+++ b/counter/admin.py
@@ -26,6 +26,7 @@ from counter.models import (
     Product,
     ProductType,
     Refilling,
+    ReturnableProduct,
     Selling,
 )
 
@@ -43,6 +44,18 @@ class ProductAdmin(SearchModelAdmin):
     search_fields = ("name", "code")
 
 
+@admin.register(ReturnableProduct)
+class ReturnableProductAdmin(admin.ModelAdmin):
+    list_display = ("product", "returned_product", "max_return")
+    search_fields = (
+        "product__name",
+        "product__code",
+        "returned_product__name",
+        "returned_product__code",
+    )
+    autocomplete_fields = ("product", "returned_product")
+
+
 @admin.register(Customer)
 class CustomerAdmin(SearchModelAdmin):
     list_display = ("user", "account_id", "amount")
diff --git a/counter/forms.py b/counter/forms.py
index b509b515..665e2ded 100644
--- a/counter/forms.py
+++ b/counter/forms.py
@@ -17,6 +17,7 @@ from counter.models import (
     Eticket,
     Product,
     Refilling,
+    ReturnableProduct,
     StudentCard,
 )
 from counter.widgets.ajax_select import (
@@ -213,6 +214,25 @@ class ProductEditForm(forms.ModelForm):
         return ret
 
 
+class ReturnableProductForm(forms.ModelForm):
+    class Meta:
+        model = ReturnableProduct
+        fields = ["product", "returned_product", "max_return"]
+        widgets = {
+            "product": AutoCompleteSelectProduct(),
+            "returned_product": AutoCompleteSelectProduct(),
+        }
+
+    def save(self, commit: bool = True) -> ReturnableProduct:  # noqa FBT
+        instance: ReturnableProduct = super().save(commit=commit)
+        if commit:
+            # This is expensive, but we don't have a task queue to offload it.
+            # Hopefully, creations and updates of returnable products
+            # occur very rarely
+            instance.update_balances()
+        return instance
+
+
 class CashSummaryFormBase(forms.Form):
     begin_date = forms.DateTimeField(
         label=_("Begin date"), widget=SelectDateTime, required=False
diff --git a/counter/migrations/0030_returnableproduct_returnableproductbalance_and_more.py b/counter/migrations/0030_returnableproduct_returnableproductbalance_and_more.py
new file mode 100644
index 00000000..0ec3d95e
--- /dev/null
+++ b/counter/migrations/0030_returnableproduct_returnableproductbalance_and_more.py
@@ -0,0 +1,126 @@
+# Generated by Django 4.2.17 on 2025-03-05 14:03
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+from django.db.migrations.state import StateApps
+
+
+def migrate_cons_balances(apps: StateApps, schema_editor):
+    ReturnableProduct = apps.get_model("counter", "ReturnableProduct")
+    Product = apps.get_model("counter", "Product")
+
+    cons = Product.objects.filter(pk=settings.SITH_ECOCUP_CONS).first()
+    dcons = Product.objects.filter(pk=settings.SITH_ECOCUP_DECO).first()
+    if not cons or not dcons:
+        return
+    returnable = ReturnableProduct.objects.create(
+        product=cons, returned_product=dcons, max_return=settings.SITH_ECOCUP_LIMIT
+    )
+    returnable.update_balances()
+
+
+class Migration(migrations.Migration):
+    dependencies = [("counter", "0029_alter_selling_label")]
+
+    operations = [
+        migrations.CreateModel(
+            name="ReturnableProduct",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                (
+                    "max_return",
+                    models.PositiveSmallIntegerField(
+                        default=0,
+                        help_text=(
+                            "The maximum number of items a customer can return "
+                            "without having actually bought them."
+                        ),
+                        verbose_name="maximum returns",
+                    ),
+                ),
+                (
+                    "product",
+                    models.OneToOneField(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="cons",
+                        to="counter.product",
+                        verbose_name="returnable product",
+                    ),
+                ),
+                (
+                    "returned_product",
+                    models.OneToOneField(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="dcons",
+                        to="counter.product",
+                        verbose_name="returned product",
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name": "returnable product",
+                "verbose_name_plural": "returnable products",
+            },
+        ),
+        migrations.AddConstraint(
+            model_name="returnableproduct",
+            constraint=models.CheckConstraint(
+                check=models.Q(
+                    ("product", models.F("returned_product")), _negated=True
+                ),
+                name="returnableproduct_product_different_from_returned",
+                violation_error_message="The returnable product cannot be the same as the returned one",
+            ),
+        ),
+        migrations.CreateModel(
+            name="ReturnableProductBalance",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("balance", models.SmallIntegerField(blank=True, default=0)),
+                (
+                    "customer",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="return_balances",
+                        to="counter.customer",
+                    ),
+                ),
+                (
+                    "returnable",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="balances",
+                        to="counter.returnableproduct",
+                    ),
+                ),
+            ],
+        ),
+        migrations.AddConstraint(
+            model_name="returnableproductbalance",
+            constraint=models.UniqueConstraint(
+                fields=("customer", "returnable"),
+                name="returnable_product_unique_type_per_customer",
+            ),
+        ),
+        migrations.RunPython(
+            migrate_cons_balances, reverse_code=migrations.RunPython.noop, elidable=True
+        ),
+        migrations.RemoveField(model_name="customer", name="recorded_products"),
+    ]
diff --git a/counter/models.py b/counter/models.py
index 903f487e..7db593e7 100644
--- a/counter/models.py
+++ b/counter/models.py
@@ -21,7 +21,7 @@ import string
 from datetime import date, datetime, timedelta
 from datetime import timezone as tz
 from decimal import Decimal
-from typing import Self
+from typing import Literal, Self
 
 from dict2xml import dict2xml
 from django.conf import settings
@@ -93,7 +93,6 @@ class Customer(models.Model):
     user = models.OneToOneField(User, primary_key=True, on_delete=models.CASCADE)
     account_id = models.CharField(_("account id"), max_length=10, unique=True)
     amount = CurrencyField(_("amount"), default=0)
-    recorded_products = models.IntegerField(_("recorded product"), default=0)
 
     objects = CustomerQuerySet.as_manager()
 
@@ -105,24 +104,50 @@ class Customer(models.Model):
     def __str__(self):
         return "%s - %s" % (self.user.username, self.account_id)
 
-    def save(self, *args, allow_negative=False, is_selling=False, **kwargs):
+    def save(self, *args, allow_negative=False, **kwargs):
         """is_selling : tell if the current action is a selling
         allow_negative : ignored if not a selling. Allow a selling to put the account in negative
         Those two parameters avoid blocking the save method of a customer if his account is negative.
         """
-        if self.amount < 0 and (is_selling and not allow_negative):
+        if self.amount < 0 and not allow_negative:
             raise ValidationError(_("Not enough money"))
         super().save(*args, **kwargs)
 
     def get_absolute_url(self):
         return reverse("core:user_account", kwargs={"user_id": self.user.pk})
 
-    @property
-    def can_record(self):
-        return self.recorded_products > -settings.SITH_ECOCUP_LIMIT
+    def update_returnable_balance(self):
+        """Update all returnable balances of this user to their real amount."""
 
-    def can_record_more(self, number):
-        return self.recorded_products - number >= -settings.SITH_ECOCUP_LIMIT
+        def purchases_qs(outer_ref: Literal["product_id", "returned_product_id"]):
+            return (
+                Selling.objects.filter(customer=self, product=OuterRef(outer_ref))
+                .values("product")
+                .annotate(quantity=Sum("quantity", default=0))
+                .values("quantity")
+            )
+
+        balances = (
+            ReturnableProduct.objects.annotate_balance_for(self)
+            .annotate(
+                nb_cons=Coalesce(Subquery(purchases_qs("product_id")), 0),
+                nb_dcons=Coalesce(Subquery(purchases_qs("returned_product_id")), 0),
+            )
+            .annotate(new_balance=F("nb_cons") - F("nb_dcons"))
+            .values("id", "new_balance")
+        )
+        updated_balances = [
+            ReturnableProductBalance(
+                customer=self, returnable_id=b["id"], balance=b["new_balance"]
+            )
+            for b in balances
+        ]
+        ReturnableProductBalance.objects.bulk_create(
+            updated_balances,
+            update_conflicts=True,
+            update_fields=["balance"],
+            unique_fields=["customer", "returnable"],
+        )
 
     @property
     def can_buy(self) -> bool:
@@ -378,14 +403,6 @@ class Product(models.Model):
     def get_absolute_url(self):
         return reverse("counter:product_list")
 
-    @property
-    def is_record_product(self):
-        return self.id == settings.SITH_ECOCUP_CONS
-
-    @property
-    def is_unrecord_product(self):
-        return self.id == settings.SITH_ECOCUP_DECO
-
     def is_owned_by(self, user):
         """Method to see if that object can be edited by the given user."""
         if user.is_anonymous:
@@ -859,7 +876,7 @@ class Selling(models.Model):
         self.full_clean()
         if not self.is_validated:
             self.customer.amount -= self.quantity * self.unit_price
-            self.customer.save(allow_negative=allow_negative, is_selling=True)
+            self.customer.save(allow_negative=allow_negative)
             self.is_validated = True
         user = self.customer.user
         if user.was_subscribed:
@@ -944,6 +961,7 @@ class Selling(models.Model):
             self.customer.amount += self.quantity * self.unit_price
             self.customer.save()
         super().delete(*args, **kwargs)
+        self.customer.update_returnable_balance()
 
     def send_mail_customer(self):
         event = self.product.eticket.event_title or _("Unknown event")
@@ -1210,3 +1228,134 @@ class StudentCard(models.Model):
         if isinstance(obj, User):
             return StudentCard.can_create(self.customer, obj)
         return False
+
+
+class ReturnableProductQuerySet(models.QuerySet):
+    def annotate_balance_for(self, customer: Customer):
+        return self.annotate(
+            balance=Coalesce(
+                Subquery(
+                    ReturnableProductBalance.objects.filter(
+                        returnable=OuterRef("pk"), customer=customer
+                    ).values("balance")
+                ),
+                0,
+            )
+        )
+
+
+class ReturnableProduct(models.Model):
+    """A returnable relation between two products (*consigne/déconsigne*)."""
+
+    product = models.OneToOneField(
+        to=Product,
+        on_delete=models.CASCADE,
+        related_name="cons",
+        verbose_name=_("returnable product"),
+    )
+    returned_product = models.OneToOneField(
+        to=Product,
+        on_delete=models.CASCADE,
+        related_name="dcons",
+        verbose_name=_("returned product"),
+    )
+    max_return = models.PositiveSmallIntegerField(
+        _("maximum returns"),
+        default=0,
+        help_text=_(
+            "The maximum number of items a customer can return "
+            "without having actually bought them."
+        ),
+    )
+
+    objects = ReturnableProductQuerySet.as_manager()
+
+    class Meta:
+        verbose_name = _("returnable product")
+        verbose_name_plural = _("returnable products")
+        constraints = [
+            models.CheckConstraint(
+                check=~Q(product=F("returned_product")),
+                name="returnableproduct_product_different_from_returned",
+                violation_error_message=_(
+                    "The returnable product cannot be the same as the returned one"
+                ),
+            )
+        ]
+
+    def __str__(self):
+        return f"returnable product ({self.product_id} -> {self.returned_product_id})"
+
+    def update_balances(self):
+        """Update all returnable balances linked to this object.
+
+        Call this when a ReturnableProduct is created or updated.
+
+        Warning:
+            This function is expensive (around a few seconds),
+            so try not to run it outside a management command
+            or a task.
+        """
+
+        def product_balance_subquery(product_id: int):
+            return Subquery(
+                Selling.objects.filter(customer=OuterRef("pk"), product_id=product_id)
+                .values("customer")
+                .annotate(res=Sum("quantity"))
+                .values("res")
+            )
+
+        old_balance_subquery = Subquery(
+            ReturnableProductBalance.objects.filter(
+                customer=OuterRef("pk"), returnable=self
+            ).values("balance")
+        )
+        new_balances = (
+            Customer.objects.annotate(
+                nb_cons=Coalesce(product_balance_subquery(self.product_id), 0),
+                nb_dcons=Coalesce(
+                    product_balance_subquery(self.returned_product_id), 0
+                ),
+            )
+            .annotate(new_balance=F("nb_cons") - F("nb_dcons"))
+            .exclude(new_balance=Coalesce(old_balance_subquery, 0))
+            .values("pk", "new_balance")
+        )
+        updates = [
+            ReturnableProductBalance(
+                customer_id=c["pk"], returnable=self, balance=c["new_balance"]
+            )
+            for c in new_balances
+        ]
+        ReturnableProductBalance.objects.bulk_create(
+            updates,
+            update_conflicts=True,
+            update_fields=["balance"],
+            unique_fields=["customer_id", "returnable"],
+        )
+
+
+class ReturnableProductBalance(models.Model):
+    """The returnable products balances of a customer"""
+
+    customer = models.ForeignKey(
+        to=Customer, on_delete=models.CASCADE, related_name="return_balances"
+    )
+    returnable = models.ForeignKey(
+        to=ReturnableProduct, on_delete=models.CASCADE, related_name="balances"
+    )
+    balance = models.SmallIntegerField(blank=True, default=0)
+
+    class Meta:
+        constraints = [
+            models.UniqueConstraint(
+                fields=["customer", "returnable"],
+                name="returnable_product_unique_type_per_customer",
+            )
+        ]
+
+    def __str__(self):
+        return (
+            f"return balance of {self.customer} "
+            f"for {self.returnable.product_id} : {self.balance}"
+        )
diff --git a/counter/templates/counter/returnable_list.jinja b/counter/templates/counter/returnable_list.jinja
new file mode 100644
index 00000000..cc107a30
--- /dev/null
+++ b/counter/templates/counter/returnable_list.jinja
@@ -0,0 +1,67 @@
+{% extends "core/base.jinja" %}
+
+{% block title %}
+  {% trans %}Returnable products{% endtrans %}
+{% endblock %}
+
+{% block additional_js %}
+{% endblock %}
+
+{% block additional_css %}
+  <link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
+  <link rel="stylesheet" href="{{ static("counter/css/admin.scss") }}">
+  <link rel="stylesheet" href="{{ static("bundled/core/components/ajax-select-index.css") }}">
+  <link rel="stylesheet" href="{{ static("core/components/ajax-select.scss") }}">
+{% endblock %}
+
+{% block content %}
+  <h3 class="margin-bottom">{% trans %}Returnable products{% endtrans %}</h3>
+  {% if user.has_perm("counter.add_returnableproduct") %}
+    <a href="{{ url('counter:create_returnable') }}" class="btn btn-blue margin-bottom">
+      {% trans %}New returnable product{% endtrans %} <i class="fa fa-plus"></i>
+    </a>
+  {% endif %}
+  <div class="product-group">
+    {% for returnable in object_list %}
+      {% if user.has_perm("counter.change_returnableproduct") %}
+        <a
+          class="card card-row shadow clickable"
+          href="{{ url("counter:edit_returnable", returnable_id=returnable.id) }}"
+        >
+      {% else %}
+        <div class="card card-row shadow">
+      {% endif %}
+      {% if returnable.product.icon %}
+        <img
+          class="card-image"
+          src="{{ returnable.product.icon.url }}"
+          alt="{{ returnable.product.name }}"
+        >
+      {% else %}
+        <i class="fa-regular fa-image fa-2x card-image"></i>
+      {% endif %}
+      <div class="card-content">
+        <strong class="card-title">{{ returnable.product }}</strong>
+        <p>{% trans %}Returned product{% endtrans %} : {{ returnable.returned_product }}</p>
+      </div>
+      {% if user.has_perm("counter.delete_returnableproduct") %}
+        <button
+          x-data
+          class="btn btn-red btn-no-text card-top-left"
+          @click.prevent="document.location.href = '{{ url("counter:delete_returnable", returnable_id=returnable.id) }}'"
+        >
+          {# The delete link is a button with a JS event listener
+            instead of a proper <a> element,
+            because the enclosing card is already a <a>,
+            and HTML forbids nested <a> #}
+          <i class="fa fa-trash"></i>
+        </button>
+      {% endif %}
+      {% if user.has_perm("counter.change_returnableproduct") %}
+        </a>
+      {% else %}
+        </div>
+      {% endif %}
+    {% endfor %}
+  </div>
+{% endblock content %}
diff --git a/counter/tests/test_counter.py b/counter/tests/test_counter.py
index ab2ab946..f0773897 100644
--- a/counter/tests/test_counter.py
+++ b/counter/tests/test_counter.py
@@ -28,17 +28,19 @@ from django.utils import timezone
 from django.utils.timezone import localdate, now
 from freezegun import freeze_time
 from model_bakery import baker
+from pytest_django.asserts import assertRedirects
 
 from club.models import Club, Membership
 from core.baker_recipes import board_user, subscriber_user, very_old_subscriber_user
 from core.models import BanGroup, User
-from counter.baker_recipes import product_recipe
+from counter.baker_recipes import product_recipe, sale_recipe
 from counter.models import (
     Counter,
     Customer,
     Permanency,
     Product,
     Refilling,
+    ReturnableProduct,
     Selling,
 )
 
@@ -97,7 +99,7 @@ class TestRefilling(TestFullClickBase):
         self,
         user: User | Customer,
         counter: Counter,
-        amount: int,
+        amount: int | float,
         client: Client | None = None,
     ) -> HttpResponse:
         used_client = client if client is not None else self.client
@@ -241,31 +243,31 @@ class TestCounterClick(TestFullClickBase):
             special_selling_price="-1.5",
         )
         cls.beer = product_recipe.make(
-            limit_age=18, selling_price="1.5", special_selling_price="1"
+            limit_age=18, selling_price=1.5, special_selling_price=1
         )
         cls.beer_tap = product_recipe.make(
-            limit_age=18,
-            tray=True,
-            selling_price="1.5",
-            special_selling_price="1",
+            limit_age=18, tray=True, selling_price=1.5, special_selling_price=1
         )
-
         cls.snack = product_recipe.make(
-            limit_age=0, selling_price="1.5", special_selling_price="1"
+            limit_age=0, selling_price=1.5, special_selling_price=1
         )
         cls.stamps = product_recipe.make(
-            limit_age=0, selling_price="1.5", special_selling_price="1"
+            limit_age=0, selling_price=1.5, special_selling_price=1
+        )
+        ReturnableProduct.objects.all().delete()
+        cls.cons = baker.make(Product, selling_price=1)
+        cls.dcons = baker.make(Product, selling_price=-1)
+        baker.make(
+            ReturnableProduct,
+            product=cls.cons,
+            returned_product=cls.dcons,
+            max_return=3,
         )
-
-        cls.cons = Product.objects.get(id=settings.SITH_ECOCUP_CONS)
-        cls.dcons = Product.objects.get(id=settings.SITH_ECOCUP_DECO)
 
         cls.counter.products.add(
             cls.gift, cls.beer, cls.beer_tap, cls.snack, cls.cons, cls.dcons
         )
-
         cls.other_counter.products.add(cls.snack)
-
         cls.club_counter.products.add(cls.stamps)
 
     def login_in_bar(self, barmen: User | None = None):
@@ -309,57 +311,36 @@ class TestCounterClick(TestFullClickBase):
     def test_click_eboutic_failure(self):
         eboutic = baker.make(Counter, type="EBOUTIC")
         self.client.force_login(self.club_admin)
-        assert (
-            self.submit_basket(
-                self.customer,
-                [BasketItem(self.stamps.id, 5)],
-                counter=eboutic,
-            ).status_code
-            == 404
+        res = self.submit_basket(
+            self.customer, [BasketItem(self.stamps.id, 5)], counter=eboutic
         )
+        assert res.status_code == 404
 
     def test_click_office_success(self):
         self.refill_user(self.customer, 10)
         self.client.force_login(self.club_admin)
-
-        assert (
-            self.submit_basket(
-                self.customer,
-                [BasketItem(self.stamps.id, 5)],
-                counter=self.club_counter,
-            ).status_code
-            == 302
+        res = self.submit_basket(
+            self.customer, [BasketItem(self.stamps.id, 5)], counter=self.club_counter
         )
+        assert res.status_code == 302
         assert self.updated_amount(self.customer) == Decimal("2.5")
 
         # Test no special price on office counter
         self.refill_user(self.club_admin, 10)
-
-        assert (
-            self.submit_basket(
-                self.club_admin,
-                [BasketItem(self.stamps.id, 1)],
-                counter=self.club_counter,
-            ).status_code
-            == 302
+        res = self.submit_basket(
+            self.club_admin, [BasketItem(self.stamps.id, 1)], counter=self.club_counter
         )
+        assert res.status_code == 302
 
         assert self.updated_amount(self.club_admin) == Decimal("8.5")
 
     def test_click_bar_success(self):
         self.refill_user(self.customer, 10)
         self.login_in_bar(self.barmen)
-
-        assert (
-            self.submit_basket(
-                self.customer,
-                [
-                    BasketItem(self.beer.id, 2),
-                    BasketItem(self.snack.id, 1),
-                ],
-            ).status_code
-            == 302
+        res = self.submit_basket(
+            self.customer, [BasketItem(self.beer.id, 2), BasketItem(self.snack.id, 1)]
         )
+        assert res.status_code == 302
 
         assert self.updated_amount(self.customer) == Decimal("5.5")
 
@@ -378,29 +359,13 @@ class TestCounterClick(TestFullClickBase):
         self.login_in_bar(self.barmen)
 
         # Not applying tray price
-        assert (
-            self.submit_basket(
-                self.customer,
-                [
-                    BasketItem(self.beer_tap.id, 2),
-                ],
-            ).status_code
-            == 302
-        )
-
+        res = self.submit_basket(self.customer, [BasketItem(self.beer_tap.id, 2)])
+        assert res.status_code == 302
         assert self.updated_amount(self.customer) == Decimal("17")
 
         # Applying tray price
-        assert (
-            self.submit_basket(
-                self.customer,
-                [
-                    BasketItem(self.beer_tap.id, 7),
-                ],
-            ).status_code
-            == 302
-        )
-
+        res = self.submit_basket(self.customer, [BasketItem(self.beer_tap.id, 7)])
+        assert res.status_code == 302
         assert self.updated_amount(self.customer) == Decimal("8")
 
     def test_click_alcool_unauthorized(self):
@@ -410,28 +375,14 @@ class TestCounterClick(TestFullClickBase):
             self.refill_user(user, 10)
 
             # Buy product without age limit
-            assert (
-                self.submit_basket(
-                    user,
-                    [
-                        BasketItem(self.snack.id, 2),
-                    ],
-                ).status_code
-                == 302
-            )
+            res = self.submit_basket(user, [BasketItem(self.snack.id, 2)])
+            assert res.status_code == 302
 
             assert self.updated_amount(user) == Decimal("7")
 
             # Buy product without age limit
-            assert (
-                self.submit_basket(
-                    user,
-                    [
-                        BasketItem(self.beer.id, 2),
-                    ],
-                ).status_code
-                == 200
-            )
+            res = self.submit_basket(user, [BasketItem(self.beer.id, 2)])
+            assert res.status_code == 200
 
             assert self.updated_amount(user) == Decimal("7")
 
@@ -443,12 +394,7 @@ class TestCounterClick(TestFullClickBase):
             self.customer_old_can_not_buy,
         ]:
             self.refill_user(user, 10)
-            resp = self.submit_basket(
-                user,
-                [
-                    BasketItem(self.snack.id, 2),
-                ],
-            )
+            resp = self.submit_basket(user, [BasketItem(self.snack.id, 2)])
             assert resp.status_code == 302
             assert resp.url == resolve_url(self.counter)
 
@@ -456,44 +402,28 @@ class TestCounterClick(TestFullClickBase):
 
     def test_click_user_without_customer(self):
         self.login_in_bar()
-        assert (
-            self.submit_basket(
-                self.customer_can_not_buy,
-                [
-                    BasketItem(self.snack.id, 2),
-                ],
-            ).status_code
-            == 404
+        res = self.submit_basket(
+            self.customer_can_not_buy, [BasketItem(self.snack.id, 2)]
         )
+        assert res.status_code == 404
 
     def test_click_allowed_old_subscriber(self):
         self.login_in_bar()
         self.refill_user(self.customer_old_can_buy, 10)
-        assert (
-            self.submit_basket(
-                self.customer_old_can_buy,
-                [
-                    BasketItem(self.snack.id, 2),
-                ],
-            ).status_code
-            == 302
+        res = self.submit_basket(
+            self.customer_old_can_buy, [BasketItem(self.snack.id, 2)]
         )
+        assert res.status_code == 302
 
         assert self.updated_amount(self.customer_old_can_buy) == Decimal("7")
 
     def test_click_wrong_counter(self):
         self.login_in_bar()
         self.refill_user(self.customer, 10)
-        assert (
-            self.submit_basket(
-                self.customer,
-                [
-                    BasketItem(self.snack.id, 2),
-                ],
-                counter=self.other_counter,
-            ).status_code
-            == 302  # Redirect to counter main
+        res = self.submit_basket(
+            self.customer, [BasketItem(self.snack.id, 2)], counter=self.other_counter
         )
+        assertRedirects(res, self.other_counter.get_absolute_url())
 
         # We want to test sending requests from another counter while
         # we are currently registered to another counter
@@ -502,42 +432,25 @@ class TestCounterClick(TestFullClickBase):
         # that using a client not logged to a counter
         # where another client is logged still isn't authorized.
         client = Client()
-        assert (
-            self.submit_basket(
-                self.customer,
-                [
-                    BasketItem(self.snack.id, 2),
-                ],
-                counter=self.counter,
-                client=client,
-            ).status_code
-            == 302  # Redirect to counter main
+        res = self.submit_basket(
+            self.customer,
+            [BasketItem(self.snack.id, 2)],
+            counter=self.counter,
+            client=client,
         )
+        assertRedirects(res, self.counter.get_absolute_url())
 
         assert self.updated_amount(self.customer) == Decimal("10")
 
     def test_click_not_connected(self):
         self.refill_user(self.customer, 10)
-        assert (
-            self.submit_basket(
-                self.customer,
-                [
-                    BasketItem(self.snack.id, 2),
-                ],
-            ).status_code
-            == 302  # Redirect to counter main
-        )
+        res = self.submit_basket(self.customer, [BasketItem(self.snack.id, 2)])
+        assertRedirects(res, self.counter.get_absolute_url())
 
-        assert (
-            self.submit_basket(
-                self.customer,
-                [
-                    BasketItem(self.snack.id, 2),
-                ],
-                counter=self.club_counter,
-            ).status_code
-            == 403
+        res = self.submit_basket(
+            self.customer, [BasketItem(self.snack.id, 2)], counter=self.club_counter
         )
+        assert res.status_code == 403
 
         assert self.updated_amount(self.customer) == Decimal("10")
 
@@ -545,15 +458,8 @@ class TestCounterClick(TestFullClickBase):
         self.refill_user(self.customer, 10)
         self.login_in_bar()
 
-        assert (
-            self.submit_basket(
-                self.customer,
-                [
-                    BasketItem(self.stamps.id, 2),
-                ],
-            ).status_code
-            == 200
-        )
+        res = self.submit_basket(self.customer, [BasketItem(self.stamps.id, 2)])
+        assert res.status_code == 200
         assert self.updated_amount(self.customer) == Decimal("10")
 
     def test_click_product_invalid(self):
@@ -561,36 +467,24 @@ class TestCounterClick(TestFullClickBase):
         self.login_in_bar()
 
         for item in [
-            BasketItem("-1", 2),
+            BasketItem(-1, 2),
             BasketItem(self.beer.id, -1),
             BasketItem(None, 1),
             BasketItem(self.beer.id, None),
             BasketItem(None, None),
         ]:
-            assert (
-                self.submit_basket(
-                    self.customer,
-                    [item],
-                ).status_code
-                == 200
-            )
+            assert self.submit_basket(self.customer, [item]).status_code == 200
 
             assert self.updated_amount(self.customer) == Decimal("10")
 
     def test_click_not_enough_money(self):
         self.refill_user(self.customer, 10)
         self.login_in_bar()
-
-        assert (
-            self.submit_basket(
-                self.customer,
-                [
-                    BasketItem(self.beer_tap.id, 5),
-                    BasketItem(self.beer.id, 10),
-                ],
-            ).status_code
-            == 200
+        res = self.submit_basket(
+            self.customer,
+            [BasketItem(self.beer_tap.id, 5), BasketItem(self.beer.id, 10)],
         )
+        assert res.status_code == 200
 
         assert self.updated_amount(self.customer) == Decimal("10")
 
@@ -606,116 +500,73 @@ class TestCounterClick(TestFullClickBase):
     def test_selling_ordering(self):
         # Cheaper items should be processed with a higher priority
         self.login_in_bar(self.barmen)
-
-        assert (
-            self.submit_basket(
-                self.customer,
-                [
-                    BasketItem(self.beer.id, 1),
-                    BasketItem(self.gift.id, 1),
-                ],
-            ).status_code
-            == 302
+        res = self.submit_basket(
+            self.customer, [BasketItem(self.beer.id, 1), BasketItem(self.gift.id, 1)]
         )
+        assert res.status_code == 302
 
         assert self.updated_amount(self.customer) == 0
 
     def test_recordings(self):
         self.refill_user(self.customer, self.cons.selling_price * 3)
         self.login_in_bar(self.barmen)
-        assert (
-            self.submit_basket(
-                self.customer,
-                [BasketItem(self.cons.id, 3)],
-            ).status_code
-            == 302
-        )
+        res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)])
+        assert res.status_code == 302
         assert self.updated_amount(self.customer) == 0
+        assert list(
+            self.customer.customer.return_balances.values("returnable", "balance")
+        ) == [{"returnable": self.cons.cons.id, "balance": 3}]
 
-        assert (
-            self.submit_basket(
-                self.customer,
-                [BasketItem(self.dcons.id, 3)],
-            ).status_code
-            == 302
-        )
-
+        res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 3)])
+        assert res.status_code == 302
         assert self.updated_amount(self.customer) == self.dcons.selling_price * -3
 
-        assert (
-            self.submit_basket(
-                self.customer,
-                [BasketItem(self.dcons.id, settings.SITH_ECOCUP_LIMIT)],
-            ).status_code
-            == 302
+        res = self.submit_basket(
+            self.customer, [BasketItem(self.dcons.id, self.dcons.dcons.max_return)]
         )
+        # from now on, the user amount should not change
+        expected_amount = self.dcons.selling_price * (-3 - self.dcons.dcons.max_return)
+        assert res.status_code == 302
+        assert self.updated_amount(self.customer) == expected_amount
 
-        assert self.updated_amount(self.customer) == self.dcons.selling_price * (
-            -3 - settings.SITH_ECOCUP_LIMIT
-        )
+        res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 1)])
+        assert res.status_code == 200
+        assert self.updated_amount(self.customer) == expected_amount
 
-        assert (
-            self.submit_basket(
-                self.customer,
-                [BasketItem(self.dcons.id, 1)],
-            ).status_code
-            == 200
-        )
-
-        assert self.updated_amount(self.customer) == self.dcons.selling_price * (
-            -3 - settings.SITH_ECOCUP_LIMIT
-        )
-
-        assert (
-            self.submit_basket(
-                self.customer,
-                [
-                    BasketItem(self.cons.id, 1),
-                    BasketItem(self.dcons.id, 1),
-                ],
-            ).status_code
-            == 302
-        )
-
-        assert self.updated_amount(self.customer) == self.dcons.selling_price * (
-            -3 - settings.SITH_ECOCUP_LIMIT
+        res = self.submit_basket(
+            self.customer, [BasketItem(self.cons.id, 1), BasketItem(self.dcons.id, 1)]
         )
+        assert res.status_code == 302
+        assert self.updated_amount(self.customer) == expected_amount
 
     def test_recordings_when_negative(self):
-        self.refill_user(
-            self.customer,
-            self.cons.selling_price * 3 + Decimal(self.beer.selling_price),
+        sale_recipe.make(
+            customer=self.customer.customer,
+            product=self.dcons,
+            unit_price=self.dcons.selling_price,
+            quantity=10,
         )
-        self.customer.customer.recorded_products = settings.SITH_ECOCUP_LIMIT * -10
-        self.customer.customer.save()
+        self.customer.customer.update_returnable_balance()
         self.login_in_bar(self.barmen)
-        assert (
-            self.submit_basket(
-                self.customer,
-                [BasketItem(self.dcons.id, 1)],
-            ).status_code
-            == 200
-        )
-        assert self.updated_amount(
-            self.customer
-        ) == self.cons.selling_price * 3 + Decimal(self.beer.selling_price)
-        assert (
-            self.submit_basket(
-                self.customer,
-                [BasketItem(self.cons.id, 3)],
-            ).status_code
-            == 302
-        )
-        assert self.updated_amount(self.customer) == Decimal(self.beer.selling_price)
+        res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 1)])
+        assert res.status_code == 200
+        assert self.updated_amount(self.customer) == self.dcons.selling_price * -10
 
+        res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)])
+        assert res.status_code == 302
         assert (
-            self.submit_basket(
-                self.customer,
-                [BasketItem(self.beer.id, 1)],
-            ).status_code
-            == 302
+            self.updated_amount(self.customer)
+            == self.dcons.selling_price * -10 - self.cons.selling_price * 3
+        )
+
+        res = self.submit_basket(self.customer, [BasketItem(self.beer.id, 1)])
+        assert res.status_code == 302
+        assert (
+            self.updated_amount(self.customer)
+            == self.dcons.selling_price * -10
+            - self.cons.selling_price * 3
+            - self.beer.selling_price
         )
-        assert self.updated_amount(self.customer) == 0
 
 
 class TestCounterStats(TestCase):
diff --git a/counter/tests/test_customer.py b/counter/tests/test_customer.py
index bc3f4fb4..56daccb1 100644
--- a/counter/tests/test_customer.py
+++ b/counter/tests/test_customer.py
@@ -14,12 +14,13 @@ from model_bakery import baker
 from club.models import Membership
 from core.baker_recipes import board_user, subscriber_user
 from core.models import User
-from counter.baker_recipes import refill_recipe, sale_recipe
+from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe
 from counter.models import (
     BillingInfo,
     Counter,
     Customer,
     Refilling,
+    ReturnableProduct,
     Selling,
     StudentCard,
 )
@@ -482,3 +483,31 @@ def test_update_balance():
     for customer, amount in zip(customers, [40, 10, 20, 40, 0], strict=False):
         customer.refresh_from_db()
         assert customer.amount == amount
+
+
+@pytest.mark.django_db
+def test_update_returnable_balance():
+    ReturnableProduct.objects.all().delete()
+    customer = baker.make(Customer)
+    products = product_recipe.make(selling_price=0, _quantity=4, _bulk_create=True)
+    returnables = [
+        baker.make(
+            ReturnableProduct, product=products[0], returned_product=products[1]
+        ),
+        baker.make(
+            ReturnableProduct, product=products[2], returned_product=products[3]
+        ),
+    ]
+    balance_qs = ReturnableProduct.objects.annotate_balance_for(customer)
+    assert not customer.return_balances.exists()
+    assert list(balance_qs.values_list("balance", flat=True)) == [0, 0]
+
+    sale_recipe.make(customer=customer, product=products[0], unit_price=0, quantity=5)
+    sale_recipe.make(customer=customer, product=products[2], unit_price=0, quantity=1)
+    sale_recipe.make(customer=customer, product=products[3], unit_price=0, quantity=3)
+    customer.update_returnable_balance()
+    assert list(customer.return_balances.values("returnable_id", "balance")) == [
+        {"returnable_id": returnables[0].id, "balance": 5},
+        {"returnable_id": returnables[1].id, "balance": -2},
+    ]
+    assert set(balance_qs.values_list("balance", flat=True)) == {-2, 5}
diff --git a/counter/tests/test_returnable_product.py b/counter/tests/test_returnable_product.py
new file mode 100644
index 00000000..b25b45d6
--- /dev/null
+++ b/counter/tests/test_returnable_product.py
@@ -0,0 +1,37 @@
+import pytest
+from model_bakery import baker
+
+from counter.baker_recipes import refill_recipe, sale_recipe
+from counter.models import Customer, ReturnableProduct
+
+
+@pytest.mark.django_db
+def test_update_returnable_product_balance():
+    Customer.objects.all().delete()
+    ReturnableProduct.objects.all().delete()
+    customers = baker.make(Customer, _quantity=2, _bulk_create=True)
+    refill_recipe.make(customer=iter(customers), _quantity=2, amount=100)
+    returnable = baker.make(ReturnableProduct)
+    sale_recipe.make(
+        unit_price=0, quantity=3, product=returnable.product, customer=customers[0]
+    )
+    sale_recipe.make(
+        unit_price=0, quantity=1, product=returnable.product, customer=customers[0]
+    )
+    sale_recipe.make(
+        unit_price=0,
+        quantity=2,
+        product=returnable.returned_product,
+        customer=customers[0],
+    )
+    sale_recipe.make(
+        unit_price=0, quantity=4, product=returnable.product, customer=customers[1]
+    )
+
+    returnable.update_balances()
+    assert list(
+        returnable.balances.order_by("customer_id").values("customer_id", "balance")
+    ) == [
+        {"customer_id": customers[0].pk, "balance": 2},
+        {"customer_id": customers[1].pk, "balance": 4},
+    ]
diff --git a/counter/urls.py b/counter/urls.py
index 885b4b14..c07205cb 100644
--- a/counter/urls.py
+++ b/counter/urls.py
@@ -30,6 +30,10 @@ from counter.views.admin import (
     ProductTypeEditView,
     ProductTypeListView,
     RefillingDeleteView,
+    ReturnableProductCreateView,
+    ReturnableProductDeleteView,
+    ReturnableProductListView,
+    ReturnableProductUpdateView,
     SellingDeleteView,
 )
 from counter.views.auth import counter_login, counter_logout
@@ -51,10 +55,7 @@ from counter.views.home import (
     CounterMain,
 )
 from counter.views.invoice import InvoiceCallView
-from counter.views.student_card import (
-    StudentCardDeleteView,
-    StudentCardFormView,
-)
+from counter.views.student_card import StudentCardDeleteView, StudentCardFormView
 
 urlpatterns = [
     path("<int:counter_id>/", CounterMain.as_view(), name="details"),
@@ -129,6 +130,24 @@ urlpatterns = [
         ProductTypeEditView.as_view(),
         name="product_type_edit",
     ),
+    path(
+        "admin/returnable/", ReturnableProductListView.as_view(), name="returnable_list"
+    ),
+    path(
+        "admin/returnable/create/",
+        ReturnableProductCreateView.as_view(),
+        name="create_returnable",
+    ),
+    path(
+        "admin/returnable/<int:returnable_id>/",
+        ReturnableProductUpdateView.as_view(),
+        name="edit_returnable",
+    ),
+    path(
+        "admin/returnable/delete/<int:returnable_id>/",
+        ReturnableProductDeleteView.as_view(),
+        name="delete_returnable",
+    ),
     path("admin/eticket/list/", EticketListView.as_view(), name="eticket_list"),
     path("admin/eticket/new/", EticketCreateView.as_view(), name="new_eticket"),
     path(
diff --git a/counter/views/admin.py b/counter/views/admin.py
index ffe81ea0..76e4e512 100644
--- a/counter/views/admin.py
+++ b/counter/views/admin.py
@@ -15,19 +15,28 @@
 from datetime import timedelta
 
 from django.conf import settings
+from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.core.exceptions import PermissionDenied
 from django.forms import CheckboxSelectMultiple
 from django.forms.models import modelform_factory
 from django.shortcuts import get_object_or_404
 from django.urls import reverse, reverse_lazy
 from django.utils import timezone
+from django.utils.translation import gettext as _
 from django.views.generic import DetailView, ListView, TemplateView
 from django.views.generic.edit import CreateView, DeleteView, UpdateView
 
 from core.auth.mixins import CanEditMixin, CanViewMixin
 from core.utils import get_semester_code, get_start_of_semester
-from counter.forms import CounterEditForm, ProductEditForm
-from counter.models import Counter, Product, ProductType, Refilling, Selling
+from counter.forms import CounterEditForm, ProductEditForm, ReturnableProductForm
+from counter.models import (
+    Counter,
+    Product,
+    ProductType,
+    Refilling,
+    ReturnableProduct,
+    Selling,
+)
 from counter.utils import is_logged_in_counter
 from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin
 
@@ -146,6 +155,69 @@ class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
     current_tab = "products"
 
 
+class ReturnableProductListView(
+    CounterAdminTabsMixin, PermissionRequiredMixin, ListView
+):
+    model = ReturnableProduct
+    queryset = model.objects.select_related("product", "returned_product")
+    template_name = "counter/returnable_list.jinja"
+    current_tab = "returnable_products"
+    permission_required = "counter.view_returnableproduct"
+
+
+class ReturnableProductCreateView(
+    CounterAdminTabsMixin, PermissionRequiredMixin, CreateView
+):
+    form_class = ReturnableProductForm
+    template_name = "core/create.jinja"
+    current_tab = "returnable_products"
+    success_url = reverse_lazy("counter:returnable_list")
+    permission_required = "counter.add_returnableproduct"
+
+
+class ReturnableProductUpdateView(
+    CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView
+):
+    model = ReturnableProduct
+    pk_url_kwarg = "returnable_id"
+    queryset = model.objects.select_related("product", "returned_product")
+    form_class = ReturnableProductForm
+    template_name = "core/edit.jinja"
+    current_tab = "returnable_products"
+    success_url = reverse_lazy("counter:returnable_list")
+    permission_required = "counter.change_returnableproduct"
+
+    def get_context_data(self, **kwargs):
+        return super().get_context_data(**kwargs) | {
+            "object_name": _("returnable product : %(returnable)s -> %(returned)s")
+            % {
+                "returnable": self.object.product.name,
+                "returned": self.object.returned_product.name,
+            }
+        }
+
+
+class ReturnableProductDeleteView(
+    CounterAdminTabsMixin, PermissionRequiredMixin, DeleteView
+):
+    model = ReturnableProduct
+    pk_url_kwarg = "returnable_id"
+    queryset = model.objects.select_related("product", "returned_product")
+    template_name = "core/delete_confirm.jinja"
+    current_tab = "returnable_products"
+    success_url = reverse_lazy("counter:returnable_list")
+    permission_required = "counter.delete_returnableproduct"
+
+    def get_context_data(self, **kwargs):
+        return super().get_context_data(**kwargs) | {
+            "object_name": _("returnable product : %(returnable)s -> %(returned)s")
+            % {
+                "returnable": self.object.product.name,
+                "returned": self.object.returned_product.name,
+            }
+        }
+
+
 class RefillingDeleteView(DeleteView):
     """Delete a refilling (for the admins)."""
 
diff --git a/counter/views/click.py b/counter/views/click.py
index 46bf8e62..a44e841f 100644
--- a/counter/views/click.py
+++ b/counter/views/click.py
@@ -16,6 +16,7 @@ import math
 
 from django.core.exceptions import PermissionDenied
 from django.db import transaction
+from django.db.models import Q
 from django.forms import (
     BaseFormSet,
     Form,
@@ -35,7 +36,13 @@ from core.auth.mixins import CanViewMixin
 from core.models import User
 from core.utils import FormFragmentTemplateData
 from counter.forms import RefillForm
-from counter.models import Counter, Customer, Product, Selling
+from counter.models import (
+    Counter,
+    Customer,
+    Product,
+    ReturnableProduct,
+    Selling,
+)
 from counter.utils import is_logged_in_counter
 from counter.views.mixins import CounterTabsMixin
 from counter.views.student_card import StudentCardFormView
@@ -99,17 +106,22 @@ class ProductForm(Form):
 
 class BaseBasketForm(BaseFormSet):
     def clean(self):
-        super().clean()
-        if len(self) == 0:
+        if len(self.forms) == 0:
             return
 
         self._check_forms_have_errors()
+        self._check_product_are_unique()
         self._check_recorded_products(self[0].customer)
         self._check_enough_money(self[0].counter, self[0].customer)
 
     def _check_forms_have_errors(self):
         if any(len(form.errors) > 0 for form in self):
-            raise ValidationError(_("Submmited basket is invalid"))
+            raise ValidationError(_("Submitted basket is invalid"))
+
+    def _check_product_are_unique(self):
+        product_ids = {form.cleaned_data["id"] for form in self.forms}
+        if len(product_ids) != len(self.forms):
+            raise ValidationError(_("Duplicated product entries."))
 
     def _check_enough_money(self, counter: Counter, customer: Customer):
         self.total_price = sum([data["total_price"] for data in self.cleaned_data])
@@ -118,21 +130,32 @@ class BaseBasketForm(BaseFormSet):
 
     def _check_recorded_products(self, customer: Customer):
         """Check for, among other things, ecocups and pitchers"""
-        self.total_recordings = 0
-        for form in self:
-            # form.product is stored by the clean step of each formset form
-            if form.product.is_record_product:
-                self.total_recordings -= form.cleaned_data["quantity"]
-            if form.product.is_unrecord_product:
-                self.total_recordings += form.cleaned_data["quantity"]
-
-        # We don't want to block an user that have negative recordings
-        # if he isn't recording anything or reducing it's recording count
-        if self.total_recordings <= 0:
-            return
-
-        if not customer.can_record_more(self.total_recordings):
-            raise ValidationError(_("This user have reached his recording limit"))
+        items = {
+            form.cleaned_data["id"]: form.cleaned_data["quantity"]
+            for form in self.forms
+        }
+        ids = list(items.keys())
+        returnables = list(
+            ReturnableProduct.objects.filter(
+                Q(product_id__in=ids) | Q(returned_product_id__in=ids)
+            ).annotate_balance_for(customer)
+        )
+        limit_reached = []
+        for returnable in returnables:
+            returnable.balance += items.get(returnable.product_id, 0)
+        for returnable in returnables:
+            dcons = items.get(returnable.returned_product_id, 0)
+            returnable.balance -= dcons
+            if dcons and returnable.balance < -returnable.max_return:
+                limit_reached.append(returnable.returned_product)
+        if limit_reached:
+            raise ValidationError(
+                _(
+                    "This user have reached his recording limit "
+                    "for the following products : %s"
+                )
+                % ", ".join([str(p) for p in limit_reached])
+            )
 
 
 BasketForm = formset_factory(
@@ -238,8 +261,7 @@ class CounterClick(CounterTabsMixin, CanViewMixin, SingleObjectMixin, FormView):
                         customer=self.customer,
                     ).save()
 
-            self.customer.recorded_products -= formset.total_recordings
-            self.customer.save()
+            self.customer.update_returnable_balance()
 
         # Add some info for the main counter view to display
         self.request.session["last_customer"] = self.customer.user.get_display_name()
@@ -248,6 +270,37 @@ class CounterClick(CounterTabsMixin, CanViewMixin, SingleObjectMixin, FormView):
 
         return ret
 
+    def _update_returnable_balance(self, formset):
+        ids = [form.cleaned_data["id"] for form in formset]
+        returnables = list(
+            ReturnableProduct.objects.filter(
+                Q(product_id__in=ids) | Q(returned_product_id__in=ids)
+            ).annotate_balance_for(self.customer)
+        )
+        for returnable in returnables:
+            cons_quantity = next(
+                (
+                    form.cleaned_data["quantity"]
+                    for form in formset
+                    if form.cleaned_data["id"] == returnable.product_id
+                ),
+                0,
+            )
+            dcons_quantity = next(
+                (
+                    form.cleaned_data["quantity"]
+                    for form in formset
+                    if form.cleaned_data["id"] == returnable.returned_product_id
+                ),
+                0,
+            )
+            self.customer.return_balances.update_or_create(
+                returnable=returnable,
+                defaults={
+                    "balance": returnable.balance + cons_quantity - dcons_quantity
+                },
+            )
+
     def get_success_url(self):
         return resolve_url(self.object)
 
diff --git a/counter/views/mixins.py b/counter/views/mixins.py
index 5c01392a..90bd1291 100644
--- a/counter/views/mixins.py
+++ b/counter/views/mixins.py
@@ -98,6 +98,11 @@ class CounterAdminTabsMixin(TabedViewMixin):
             "slug": "product_types",
             "name": _("Product types"),
         },
+        {
+            "url": reverse_lazy("counter:returnable_list"),
+            "slug": "returnable_products",
+            "name": _("Returnable products"),
+        },
         {
             "url": reverse_lazy("counter:cash_summary_list"),
             "slug": "cash_summary",
diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po
index f4a3caee..bf5e3951 100644
--- a/locale/fr/LC_MESSAGES/django.po
+++ b/locale/fr/LC_MESSAGES/django.po
@@ -6,7 +6,7 @@
 msgid ""
 msgstr ""
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2025-03-28 13:51+0100\n"
+"POT-Creation-Date: 2025-04-04 09:31+0200\n"
 "PO-Revision-Date: 2016-07-18\n"
 "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
 "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -931,6 +931,10 @@ msgstr "rôle"
 msgid "description"
 msgstr "description"
 
+#: club/models.py
+msgid "past member"
+msgstr "ancien membre"
+
 #: club/models.py
 msgid "Email address"
 msgstr "Adresse email"
@@ -1166,8 +1170,8 @@ msgid ""
 "The following form fields are linked to the core properties of a club. Only "
 "admin users can see and edit them."
 msgstr ""
-"Les champs de formulaire suivants sont liées aux propriétés essentielles d'un "
-"club. Seuls les administrateurs peuvent voir et modifier ceux-ci."
+"Les champs de formulaire suivants sont liées aux propriétés essentielles "
+"d'un club. Seuls les administrateurs peuvent voir et modifier ceux-ci."
 
 #: club/templates/club/edit_club.jinja
 msgid "Club informations"
@@ -1178,8 +1182,8 @@ msgid ""
 "The following form fields are linked to the basic description of a club. All "
 "board members of this club can see and edit them."
 msgstr ""
-"Les champs de formulaire suivants sont liées à la description basique d'un club. "
-"Tous les membres du bureau du club peuvent voir et modifier ceux-ci."
+"Les champs de formulaire suivants sont liées à la description basique d'un "
+"club. Tous les membres du bureau du club peuvent voir et modifier ceux-ci."
 
 #: club/templates/club/mailing.jinja
 msgid "Mailing lists"
@@ -1272,10 +1276,6 @@ msgstr "Listes de diffusion"
 msgid "Posters list"
 msgstr "Liste d'affiches"
 
-#: club/views.py counter/templates/counter/counter_list.jinja
-msgid "Props"
-msgstr "Propriétés"
-
 #: com/forms.py
 msgid "Format: 16:9 | Resolution: 1920x1080"
 msgstr "Format : 16:9 | Résolution : 1920x1080"
@@ -2424,11 +2424,9 @@ msgid "Delete confirmation"
 msgstr "Confirmation de suppression"
 
 #: core/templates/core/delete_confirm.jinja
-#: core/templates/core/file_delete_confirm.jinja
-#: counter/templates/counter/fragments/delete_student_card.jinja
 #, python-format
-msgid "Are you sure you want to delete \"%(obj)s\"?"
-msgstr "Êtes-vous sûr de vouloir supprimer \"%(obj)s\" ?"
+msgid "Are you sure you want to delete \"%(name)s\"?"
+msgstr "Êtes-vous sûr de vouloir supprimer \"%(name)s\" ?"
 
 #: core/templates/core/delete_confirm.jinja
 #: core/templates/core/file_delete_confirm.jinja
@@ -2464,6 +2462,12 @@ msgstr "Mes fichiers"
 msgid "Prop"
 msgstr "Propriétés"
 
+#: core/templates/core/file_delete_confirm.jinja
+#: counter/templates/counter/fragments/delete_student_card.jinja
+#, python-format
+msgid "Are you sure you want to delete \"%(obj)s\"?"
+msgstr "Êtes-vous sûr de vouloir supprimer \"%(obj)s\" ?"
+
 #: core/templates/core/file_detail.jinja
 #: core/templates/core/file_moderation.jinja sas/templates/sas/picture.jinja
 msgid "Owner: "
@@ -3211,6 +3215,10 @@ msgstr "Gestion des produits"
 msgid "Product types management"
 msgstr "Gestion des types de produit"
 
+#: core/templates/core/user_tools.jinja
+msgid "Returnable products management"
+msgstr "Gestion des consignes"
+
 #: core/templates/core/user_tools.jinja
 #: counter/templates/counter/cash_summary_list.jinja counter/views/mixins.py
 msgid "Cash register summaries"
@@ -3351,8 +3359,8 @@ msgstr "Nom d'utilisateur, email, ou numéro de compte AE"
 
 #: core/views/forms.py
 msgid ""
-"Profile: you need to be visible on the picture, in order to be recognized (e."
-"g. by the barmen)"
+"Profile: you need to be visible on the picture, in order to be recognized "
+"(e.g. by the barmen)"
 msgstr ""
 "Photo de profil: vous devez être visible sur la photo afin d'être reconnu "
 "(par exemple par les barmen)"
@@ -3425,10 +3433,6 @@ msgstr "Famille"
 msgid "Pictures"
 msgstr "Photos"
 
-#: core/views/user.py
-msgid "Galaxy"
-msgstr "Galaxie"
-
 #: counter/apps.py sith/settings.py
 msgid "Check"
 msgstr "Chèque"
@@ -3473,10 +3477,6 @@ msgstr "Vidange de votre compte AE"
 msgid "account id"
 msgstr "numéro de compte"
 
-#: counter/models.py
-msgid "recorded product"
-msgstr "produits consignés"
-
 #: counter/models.py
 msgid "customer"
 msgstr "client"
@@ -3722,6 +3722,35 @@ msgstr "carte étudiante"
 msgid "student cards"
 msgstr "cartes étudiantes"
 
+#: counter/models.py
+msgid "returnable product"
+msgstr "produit consigné"
+
+#: counter/models.py
+msgid "returned product"
+msgstr "produit déconsigné"
+
+#: counter/models.py
+msgid "maximum returns"
+msgstr "nombre de déconsignes maximum"
+
+#: counter/models.py
+msgid ""
+"The maximum number of items a customer can return without having actually "
+"bought them."
+msgstr ""
+"Le nombre maximum d'articles qu'un client peut déconsigner sans les avoir "
+"achetés."
+
+#: counter/models.py
+msgid "returnable products"
+msgstr "produits consignés"
+
+#: counter/models.py
+msgid "The returnable product cannot be the same as the returned one"
+msgstr ""
+"Le produit consigné ne peut pas être le même que le produit de déconsigne"
+
 #: counter/templates/counter/activity.jinja
 #, python-format
 msgid "%(counter_name)s activity"
@@ -3847,6 +3876,10 @@ msgstr "Liste des comptoirs"
 msgid "New counter"
 msgstr "Nouveau comptoir"
 
+#: counter/templates/counter/counter_list.jinja
+msgid "Props"
+msgstr "Propriétés"
+
 #: counter/templates/counter/counter_list.jinja
 #: counter/templates/counter/refilling_list.jinja
 msgid "Reloads list"
@@ -3962,8 +3995,8 @@ msgstr ""
 #: counter/templates/counter/mails/account_dump.jinja
 msgid "If you think this was a mistake, please mail us at ae@utbm.fr."
 msgstr ""
-"Si vous pensez qu'il s'agit d'une erreur, veuillez envoyer un mail à ae@utbm."
-"fr."
+"Si vous pensez qu'il s'agit d'une erreur, veuillez envoyer un mail à "
+"ae@utbm.fr."
 
 #: counter/templates/counter/mails/account_dump.jinja
 msgid ""
@@ -4089,6 +4122,18 @@ msgstr "Il n'y a pas de types de produit dans ce site web."
 msgid "Seller"
 msgstr "Vendeur"
 
+#: counter/templates/counter/returnable_list.jinja counter/views/mixins.py
+msgid "Returnable products"
+msgstr "Produits consignés"
+
+#: counter/templates/counter/returnable_list.jinja
+msgid "New returnable product"
+msgstr "Nouveau produit consignable"
+
+#: counter/templates/counter/returnable_list.jinja
+msgid "Returned product"
+msgstr "Produit déconsigné"
+
 #: counter/templates/counter/stats.jinja
 #, python-format
 msgid "%(counter_name)s stats"
@@ -4121,6 +4166,11 @@ msgstr "Temps"
 msgid "Top 100 barman %(counter_name)s (all semesters)"
 msgstr "Top 100 barman %(counter_name)s (tous les semestres)"
 
+#: counter/views/admin.py
+#, python-format
+msgid "returnable product : %(returnable)s -> %(returned)s"
+msgstr "produit consigné : %(returnable)s -> %(returned)s"
+
 #: counter/views/cash.py
 msgid "10 cents"
 msgstr "10 centimes"
@@ -4174,12 +4224,20 @@ msgid "The selected product isn't available for this user"
 msgstr "Le produit sélectionné n'est pas disponnible pour cet utilisateur"
 
 #: counter/views/click.py
-msgid "Submmited basket is invalid"
+msgid "Submitted basket is invalid"
 msgstr "Le panier envoyé est invalide"
 
 #: counter/views/click.py
-msgid "This user have reached his recording limit"
-msgstr "Cet utilisateur a atteint sa limite de déconsigne"
+msgid "Duplicated product entries."
+msgstr "Saisie de produit dupliquée"
+
+#: counter/views/click.py
+#, python-format
+msgid ""
+"This user have reached his recording limit for the following products : %s"
+msgstr ""
+"Cet utilisateur a atteint sa limite de déconsigne pour les produits "
+"suivants : %s"
 
 #: counter/views/eticket.py
 msgid "people(s)"