Repository: saleor/saleor
Pull request: Add zero downtime support for multiple codes for voucher
Add zero downtime support for https://github.com/saleor/saleor/pull/14123.

Resolves https://github.com/saleor/saleor/issues/13912

<!-- Please mention all relevant issue numbers. -->

# Impact

- [ ] New migrations
- [ ] New/Updated API fields or mutations
- [ ] Deprecated API fields or mutations
- [ ] Removed API types, fields, or mutations

# Docs

<!-- Docs are stored in a separate repository: https://github.com/saleor/saleor-docs/. -->
<!-- Please provide a link to the PR that updates documentation for your changes. -->
<!-- If changes in docs are not required, please mention that in the description. -->

- [ ] Link to documentation:

# Pull Request Checklist

<!-- Please keep this section. It will make the maintainer's life easier. -->

- [ ] Privileged queries and mutations are either absent or guarded by proper permission checks
- [ ] Database queries are optimized and the number of queries is constant
- [ ] Database migrations are either absent or optimized for zero downtime
- [ ] The changes are covered by test cases


Problem statement:
Zerodowntime for VoucherCodes: add new fields + celery task
For 3.17:
- add new fields for voucher code
- add new relation between Order and VoucherCode
- add new relation between VoucherCustomer and VoucherCode
- add celery task that will copy data over from voucher.code to VoucherCode
- add celery task for Order - VoucherCode
- add celery task for VoucherCustomer - VoucherCode
- add double save functionality that will make sure the new fields are written to
- add double update for order_exipry
- add double save for VoucherCustomer
- add test for double save

Hints from issue discussion:

Commit message: Update OrderBulkCreate

Patch to review:
diff --git a/saleor/celeryconf.py b/saleor/celeryconf.py
index 2bd58a9cd81..cd30d51fc9d 100644
--- a/saleor/celeryconf.py
+++ b/saleor/celeryconf.py
@@ -33,5 +33,11 @@ def setup_celery_logging(loglevel=None, **kwargs):
     ],
     related_name="saleor3_17",
 )
+app.autodiscover_tasks(
+    packages=[
+        "saleor.order.migrations.tasks",
+    ],
+    related_name="saleor3_17",
+)
 app.autodiscover_tasks(lambda: discover_plugins_modules(settings.PLUGINS))  # type: ignore[misc] # circular import # noqa: E501
 app.autodiscover_tasks(related_name="search_tasks")
diff --git a/saleor/checkout/complete_checkout.py b/saleor/checkout/complete_checkout.py
index f148233a89c..191529f2c1b 100644
--- a/saleor/checkout/complete_checkout.py
+++ b/saleor/checkout/complete_checkout.py
@@ -97,6 +97,7 @@
 
 if TYPE_CHECKING:
     from ..app.models import App
+    from ..discount.models import Voucher, VoucherCode
     from ..plugins.manager import PluginsManager
     from ..site.models import SiteSettings
 
@@ -110,7 +111,7 @@ def _process_voucher_data_for_order(checkout_info: "CheckoutInfo") -> dict:
     :raises NotApplicable: When the voucher is not applicable in the current checkout.
     """
     checkout = checkout_info.checkout
-    voucher = get_voucher_for_checkout_info(checkout_info, with_lock=True)
+    voucher, code = get_voucher_for_checkout_info(checkout_info, with_lock=True)
 
     if checkout.voucher_code and not voucher:
         msg = "Voucher expired in meantime. Order placement aborted."
@@ -120,12 +121,17 @@ def _process_voucher_data_for_order(checkout_info: "CheckoutInfo") -> dict:
         return {}
 
     if voucher.usage_limit:
-        increase_voucher_usage(voucher)
+        # type-ignore added as we ensure that voucher code instance is created
+        increase_voucher_usage(voucher, code)  # type: ignore[arg-type]
     if voucher.apply_once_per_customer:
         customer_email = cast(str, checkout_info.get_customer_email())
-        add_voucher_usage_by_customer(voucher, customer_email)
+        # type-ignore added as we ensure that voucher code instance is created
+        add_voucher_usage_by_customer(
+            voucher, code, customer_email  # type: ignore[arg-type]
+        )
     return {
         "voucher": voucher,
+        "voucher_code": voucher.code,
     }
 
 
@@ -505,7 +511,11 @@ def _prepare_order_data(
     try:
         manager.preprocess_order_creation(checkout_info, lines)
     except TaxError:
-        release_voucher_usage(order_data.get("voucher"), order_data.get("user_email"))
+        release_voucher_usage(
+            checkout_info.voucher,
+            checkout_info.voucher_code,
+            order_data.get("user_email"),
+        )
         raise
 
     return order_data
@@ -799,6 +809,8 @@ def _process_payment(
     order_data: dict,
     manager: "PluginsManager",
     channel_slug: str,
+    voucher_code: Optional["VoucherCode"] = None,
+    voucher: Optional["Voucher"] = None,
 ) -> Transaction:
     """Process the payment assigned to checkout."""
     try:
@@ -824,7 +836,11 @@ def _process_payment(
         if not txn.is_success:
             raise PaymentError(txn.error)
     except PaymentError as e:
-        release_voucher_usage(order_data.get("voucher"), order_data.get("user_email"))
+        release_voucher_usage(
+            voucher,
+            voucher_code,
+            order_data.get("user_email"),
+        )
         raise ValidationError(str(e), code=CheckoutErrorCode.PAYMENT_ERROR.value)
     return txn
 
@@ -900,7 +916,9 @@ def complete_checkout_post_payment_part(
         if action_required:
             action_data = txn.action_required_data
             release_voucher_usage(
-                order_data.get("voucher"), order_data.get("user_email")
+                checkout_info.voucher,
+                checkout_info.voucher_code,
+                order_data.get("user_email"),
             )
 
     order = None
@@ -921,7 +939,9 @@ def complete_checkout_post_payment_part(
             checkout_info.checkout.delete()
         except InsufficientStock as e:
             release_voucher_usage(
-                order_data.get("voucher"), order_data.get("user_email")
+                checkout_info.voucher,
+                checkout_info.voucher_code,
+                order_data.get("user_email"),
             )
             gateway.payment_refund_or_void(
                 payment, manager, channel_slug=checkout_info.channel.slug
@@ -930,7 +950,9 @@ def complete_checkout_post_payment_part(
             raise error
         except GiftCardNotApplicable as e:
             release_voucher_usage(
-                order_data.get("voucher"), order_data.get("user_email")
+                checkout_info.voucher,
+                checkout_info.voucher_code,
+                order_data.get("user_email"),
             )
             gateway.payment_refund_or_void(
                 payment, manager, channel_slug=checkout_info.channel.slug
@@ -961,16 +983,20 @@ def _is_refund_ongoing(payment):
 
 def _increase_voucher_usage(checkout_info: "CheckoutInfo"):
     """Increase a voucher usage applied to the checkout."""
-    voucher = get_voucher_for_checkout_info(checkout_info, with_lock=True)
+    voucher, code = get_voucher_for_checkout_info(checkout_info, with_lock=True)
     if not voucher:
         return None
 
     if voucher.apply_once_per_customer:
         customer_email = cast(str, checkout_info.get_customer_email())
-        add_voucher_usage_by_customer(voucher, customer_email)
+        # type-ignore added as we ensure that voucher code instance is created
+        add_voucher_usage_by_customer(
+            voucher, code, customer_email  # type: ignore[arg-type]
+        )
 
     if voucher.usage_limit:
-        increase_voucher_usage(voucher)
+        # type-ignore added as we ensure that voucher code instance is created
+        increase_voucher_usage(voucher, code)  # type: ignore[arg-type]
 
 
 def _create_order_lines_from_checkout_lines(
@@ -1311,12 +1337,16 @@ def create_order_from_checkout(
             return order
         except InsufficientStock:
             release_voucher_usage(
-                checkout_info.voucher, checkout_info.checkout.get_customer_email()
+                checkout_info.voucher,
+                checkout_info.voucher_code,
+                checkout_info.checkout.get_customer_email(),
             )
             raise
         except GiftCardNotApplicable:
             release_voucher_usage(
-                checkout_info.voucher, checkout_info.checkout.get_customer_email()
+                checkout_info.voucher,
+                checkout_info.voucher_code,
+                checkout_info.checkout.get_customer_email(),
             )
             raise
 
@@ -1479,6 +1509,8 @@ def complete_checkout_with_payment(
     # who potentially can order the same product variants.
     txn = None
     channel_slug = checkout_info.channel.slug
+    voucher = checkout_info.voucher
+    voucher_code = checkout_info.voucher_code
     if payment:
         txn = _process_payment(
             payment=payment,
@@ -1488,6 +1520,8 @@ def complete_checkout_with_payment(
             order_data=order_data,
             manager=manager,
             channel_slug=channel_slug,
+            voucher_code=checkout_info.voucher_code,
+            voucher=checkout_info.voucher,
         )
 
         # As payment processing might take a while, we need to check if the payment
@@ -1516,7 +1550,10 @@ def complete_checkout_with_payment(
         lines, _ = fetch_checkout_lines(checkout, skip_recalculation=True)
         checkout_info = fetch_checkout_info(checkout, lines, manager)
 
-        checkout_info.checkout.voucher_code = None
+        # reassign voucher data that was used during payment process to allow voucher
+        # usage releasing in case of checkout complete failure
+        checkout_info.voucher = voucher
+        checkout_info.voucher_code = voucher_code
         order, action_required, action_data = complete_checkout_post_payment_part(
             manager=manager,
             checkout_info=checkout_info,
diff --git a/saleor/checkout/fetch.py b/saleor/checkout/fetch.py
index c7020daa368..48889323cfe 100644
--- a/saleor/checkout/fetch.py
+++ b/saleor/checkout/fetch.py
@@ -30,7 +30,7 @@
     from ..account.models import Address, User
     from ..channel.models import Channel
     from ..discount.interface import VariantPromotionRuleInfo, VoucherInfo
-    from ..discount.models import CheckoutLineDiscount, Voucher
+    from ..discount.models import CheckoutLineDiscount, Voucher, VoucherCode
     from ..plugins.manager import PluginsManager
     from ..product.models import (
         Collection,
@@ -78,6 +78,7 @@ class CheckoutInfo:
     tax_configuration: "TaxConfiguration"
     valid_pick_up_points: List["Warehouse"]
     voucher: Optional["Voucher"] = None
+    voucher_code: Optional["VoucherCode"] = None
 
     @property
     def valid_shipping_methods(self) -> List["ShippingMethodData"]:
@@ -312,7 +313,7 @@ def fetch_checkout_lines(
 
     if not skip_recalculation and checkout.voucher_code and lines_info:
         if not voucher:
-            voucher = get_voucher_for_checkout(
+            voucher, _ = get_voucher_for_checkout(
                 checkout, channel_slug=channel.slug, with_prefetch=True
             )
         if not voucher:
@@ -423,8 +424,12 @@ def fetch_checkout_info(
     shipping_address = checkout.shipping_address
     if shipping_channel_listings is None:
         shipping_channel_listings = channel.shipping_method_listings.all()
+
+    voucher_code = None
     if not voucher:
-        voucher = get_voucher_for_checkout(checkout, channel_slug=channel.slug)
+        voucher, voucher_code = get_voucher_for_checkout(
+            checkout, channel_slug=channel.slug
+        )
 
     delivery_method_info = get_delivery_method_info(None, shipping_address)
     checkout_info = CheckoutInfo(
@@ -438,6 +443,7 @@ def fetch_checkout_info(
         all_shipping_methods=[],
         valid_pick_up_points=[],
         voucher=voucher,
+        voucher_code=voucher_code,
     )
     if fetch_delivery_methods:
         update_delivery_method_lists_for_checkout_info(
diff --git a/saleor/checkout/tests/test_checkout.py b/saleor/checkout/tests/test_checkout.py
index f86fc12828f..6c341f36d65 100644
--- a/saleor/checkout/tests/test_checkout.py
+++ b/saleor/checkout/tests/test_checkout.py
@@ -1126,8 +1126,9 @@ def test_get_discount_for_checkout_shipping_voucher_not_applicable(
 def test_get_voucher_for_checkout_info(checkout_with_voucher, voucher):
     manager = get_plugins_manager()
     checkout_info = fetch_checkout_info(checkout_with_voucher, [], manager)
-    checkout_voucher = get_voucher_for_checkout_info(checkout_info)
+    checkout_voucher, code = get_voucher_for_checkout_info(checkout_info)
     assert checkout_voucher == voucher
+    assert code
 
 
 def test_get_voucher_for_checkout_info_expired_voucher(checkout_with_voucher, voucher):
@@ -1136,15 +1137,17 @@ def test_get_voucher_for_checkout_info_expired_voucher(checkout_with_voucher, vo
     voucher.save()
     manager = get_plugins_manager()
     checkout_info = fetch_checkout_info(checkout_with_voucher, [], manager)
-    checkout_voucher = get_voucher_for_checkout_info(checkout_info)
+    checkout_voucher, code = get_voucher_for_checkout_info(checkout_info)
     assert checkout_voucher is None
+    assert code is None
 
 
 def test_get_voucher_for_checkout_info_no_voucher_code(checkout):
     manager = get_plugins_manager()
     checkout_info = fetch_checkout_info(checkout, [], manager)
-    checkout_voucher = get_voucher_for_checkout_info(checkout_info)
+    checkout_voucher, code = get_voucher_for_checkout_info(checkout_info)
     assert checkout_voucher is None
+    assert code is None
 
 
 def test_remove_voucher_from_checkout(checkout_with_voucher, voucher_translation_fr):
diff --git a/saleor/checkout/utils.py b/saleor/checkout/utils.py
index a15d499708a..88c52627513 100644
--- a/saleor/checkout/utils.py
+++ b/saleor/checkout/utils.py
@@ -4,6 +4,7 @@
 
 import graphene
 from django.core.exceptions import ValidationError
+from django.db.models import prefetch_related_objects
 from django.utils import timezone
 from prices import Money
 
@@ -18,7 +19,7 @@
 from ..core.utils.translations import get_translation
 from ..discount import VoucherType
 from ..discount.interface import VoucherInfo, fetch_voucher_info
-from ..discount.models import NotApplicable, Voucher
+from ..discount.models import NotApplicable, Voucher, VoucherCode
 from ..discount.utils import (
     create_or_update_discount_objects_from_promotion_for_checkout,
     get_products_voucher_discount,
@@ -517,31 +518,52 @@ def get_voucher_for_checkout(
     channel_slug: str,
     with_lock: bool = False,
     with_prefetch: bool = False,
-) -> Optional[Voucher]:
+) -> Tuple[Optional[Voucher], Optional[VoucherCode]]:
     """Return voucher assigned to checkout."""
-    if checkout.voucher_code is not None:
-        vouchers = Voucher.objects
-        vouchers = vouchers.active_in_channel(
+    code = checkout.voucher_code
+    if code is None:
+        return None, None
+
+    try:
+        voucher = Voucher.objects.active_in_channel(
             date=timezone.now(), channel_slug=channel_slug
+        ).get(code=code)
+    except Voucher.DoesNotExist:
+        return None, None
+
+    voucher_code = get_or_create_voucher_code(voucher)
+
+    if with_prefetch:
+        prefetch_related_objects(
+            [voucher],
+            "products",
+            "collections",
+            "categories",
+            "variants",
+            "channel_listings",
         )
-        if with_prefetch:
-            vouchers.prefetch_related(
-                "products", "collections", "categories", "variants", "channel_listings"
-            )
-        try:
-            qs = vouchers
-            voucher = qs.get(code=checkout.voucher_code)
-            if voucher and voucher.usage_limit is not None and with_lock:
-                voucher = vouchers.select_for_update().get(code=checkout.voucher_code)
-            return voucher
-        except Voucher.DoesNotExist:
-            return None
-    return None
+
+    if voucher.usage_limit is not None and with_lock:
+        voucher_code = VoucherCode.objects.select_for_update().get(id=voucher_code.id)
+        voucher = Voucher.objects.select_for_update().get(id=voucher.id)
+
+    return voucher, voucher_code
+
+
+def get_or_create_voucher_code(voucher: Voucher) -> VoucherCode:
+    """Return voucher code for a given voucher.
+
+    If the voucher code does not exist it will be created.
+    """
+    voucher_code, _ = VoucherCode.objects.get_or_create(
+        voucher=voucher, code=voucher.code, used=voucher.used
+    )
+    return voucher_code
 
 
 def get_voucher_for_checkout_info(
     checkout_info: "CheckoutInfo", with_lock: bool = False, with_prefetch: bool = False
-) -> Optional[Voucher]:
+) -> Tuple[Optional[Voucher], Optional[VoucherCode]]:
     """Return voucher with voucher code saved in checkout if active or None."""
     checkout = checkout_info.checkout
     return get_voucher_for_checkout(
@@ -700,6 +722,7 @@ def add_voucher_to_checkout(
 
     Raise NotApplicable if voucher of given type cannot be applied.
     """
+    voucher_code = get_or_create_voucher_code(voucher)
     checkout = checkout_info.checkout
     address = checkout_info.shipping_address or checkout_info.billing_address
     discount = get_voucher_discount_for_checkout(
@@ -729,6 +752,7 @@ def add_voucher_to_checkout(
         ]
     )
     checkout_info.voucher = voucher
+    checkout_info.voucher_code = voucher_code
 
 
 def remove_promo_code_from_checkout(
@@ -756,6 +780,7 @@ def remove_voucher_code_from_checkout(
     if existing_voucher and existing_voucher.code == voucher_code:
         remove_voucher_from_checkout(checkout_info.checkout)
         checkout_info.voucher = None
+        checkout_info.voucher_code = None
         return True
     return False
 
diff --git a/saleor/discount/migrations/0055_vouchercode.py b/saleor/discount/migrations/0055_vouchercode.py
new file mode 100644
index 00000000000..1b8a1d7967c
--- /dev/null
+++ b/saleor/discount/migrations/0055_vouchercode.py
@@ -0,0 +1,49 @@
+# Generated by Django 3.2.20 on 2023-08-21 07:21
+from uuid import uuid4
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("discount", "0054_drop_sales_models"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="VoucherCode",
+            options={"ordering": ("code",)},
+            fields=[
+                (
+                    "id",
+                    models.UUIDField(
+                        default=uuid4,
+                        editable=False,
+                        primary_key=True,
+                        serialize=False,
+                        unique=True,
+                    ),
+                ),
+                ("code", models.CharField(db_index=True, max_length=255, unique=True)),
+                ("used", models.PositiveIntegerField(default=0)),
+                ("is_active", models.BooleanField(default=True)),
+                (
+                    "voucher",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="codes",
+                        to="discount.voucher",
+                        db_index=False,
+                    ),
+                ),
+            ],
+        ),
+        migrations.RunSQL(
+            sql="""
+                ALTER TABLE discount_vouchercode
+                ALTER COLUMN is_active
+                SET DEFAULT true;
+            """,
+            reverse_sql=migrations.RunSQL.noop,
+        ),
+    ]
diff --git a/saleor/discount/migrations/0056_voucher_code_indexes.py b/saleor/discount/migrations/0056_voucher_code_indexes.py
new file mode 100644
index 00000000000..a204e827662
--- /dev/null
+++ b/saleor/discount/migrations/0056_voucher_code_indexes.py
@@ -0,0 +1,18 @@
+from django.db import migrations
+from django.contrib.postgres.operations import AddIndexConcurrently
+from django.contrib.postgres.indexes import BTreeIndex
+
+
+class Migration(migrations.Migration):
+    atomic = False
+
+    dependencies = [
+        ("discount", "0055_vouchercode"),
+    ]
+
+    operations = [
+        AddIndexConcurrently(
+            model_name="vouchercode",
+            index=BTreeIndex(fields=["voucher"], name="vouchercode_voucher_idx"),
+        )
+    ]
diff --git a/saleor/discount/migrations/0057_move_codes_to_new_model.py b/saleor/discount/migrations/0057_move_codes_to_new_model.py
new file mode 100644
index 00000000000..2b2c34b0688
--- /dev/null
+++ b/saleor/discount/migrations/0057_move_codes_to_new_model.py
@@ -0,0 +1,29 @@
+# Generated by Django 3.2.20 on 2023-08-21 07:23
+
+from django.db import migrations
+from .tasks.saleor3_17 import move_codes_to_voucher_code_task
+from django.db.models.signals import post_migrate
+from django.apps import apps as registry
+
+BATCH_SIZE = 5000
+
+
+def move_codes_to_new_model(apps, schema_editor):
+    def on_migrations_complete(sender=None, **kwargs):
+        move_codes_to_voucher_code_task.delay()
+
+    sender = registry.get_app_config("discount")
+    post_migrate.connect(on_migrations_complete, weak=False, sender=sender)
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("discount", "0056_voucher_code_indexes"),
+    ]
+
+    operations = [
+        migrations.RunPython(
+            move_codes_to_new_model,
+            migrations.RunPython.noop,
+        ),
+    ]
diff --git a/saleor/discount/migrations/0058_vouchercustomer_vouchercode.py b/saleor/discount/migrations/0058_vouchercustomer_vouchercode.py
new file mode 100644
index 00000000000..2f5d4c106a9
--- /dev/null
+++ b/saleor/discount/migrations/0058_vouchercustomer_vouchercode.py
@@ -0,0 +1,25 @@
+# Generated by Django 3.2.21 on 2023-09-26 08:44
+
+import django.contrib.postgres.indexes
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("discount", "0057_move_codes_to_new_model"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="vouchercustomer",
+            name="voucher_code",
+            field=models.ForeignKey(
+                db_index=False,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="customers",
+                to="discount.vouchercode",
+            ),
+        ),
+    ]
diff --git a/saleor/discount/migrations/0059_vouchercustomer_voucher_code_index.py b/saleor/discount/migrations/0059_vouchercustomer_voucher_code_index.py
new file mode 100644
index 00000000000..76b7af53612
--- /dev/null
+++ b/saleor/discount/migrations/0059_vouchercustomer_voucher_code_index.py
@@ -0,0 +1,21 @@
+# Generated by Django 3.2.21 on 2023-09-26 08:46
+
+from django.db import migrations
+from django.contrib.postgres.indexes import BTreeIndex
+
+
+class Migration(migrations.Migration):
+    atomic = False
+
+    dependencies = [
+        ("discount", "0058_vouchercustomer_vouchercode"),
+    ]
+
+    operations = [
+        migrations.AddIndex(
+            model_name="vouchercustomer",
+            index=BTreeIndex(
+                fields=["voucher_code"], name="vouchercustomer_voucher_code_idx"
+            ),
+        ),
+    ]
diff --git a/saleor/discount/migrations/0060_set_vouchercustomer_codes.py b/saleor/discount/migrations/0060_set_vouchercustomer_codes.py
new file mode 100644
index 00000000000..b4ba92e1627
--- /dev/null
+++ b/saleor/discount/migrations/0060_set_vouchercustomer_codes.py
@@ -0,0 +1,27 @@
+# Generated by Django 3.2.21 on 2023-10-05 10:44
+
+from django.db import migrations
+from .tasks.saleor3_17 import set_voucher_customer_codes_task
+from django.apps import apps as registry
+from django.db.models.signals import post_migrate
+
+
+def set_voucher_code_to_voucher_customer(apps, schema_editor):
+    def on_migrations_complete(sender=None, **kwargs):
+        set_voucher_customer_codes_task.delay()
+
+    sender = registry.get_app_config("discount")
+    post_migrate.connect(on_migrations_complete, weak=False, sender=sender)
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("discount", "0059_vouchercustomer_voucher_code_index"),
+    ]
+
+    operations = [
+        migrations.RunPython(
+            set_voucher_code_to_voucher_customer,
+            migrations.RunPython.noop,
+        ),
+    ]
diff --git a/saleor/discount/migrations/0061_basediscount_add_voucher_code.py b/saleor/discount/migrations/0061_basediscount_add_voucher_code.py
new file mode 100644
index 00000000000..278dd1125d3
--- /dev/null
+++ b/saleor/discount/migrations/0061_basediscount_add_voucher_code.py
@@ -0,0 +1,27 @@
+# Generated by Django 3.2.21 on 2023-10-13 09:59
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("discount", "0060_set_vouchercustomer_codes"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="orderdiscount",
+            name="voucher_code",
+            field=models.CharField(blank=True, max_length=255, null=True),
+        ),
+        migrations.AddField(
+            model_name="checkoutlinediscount",
+            name="voucher_code",
+            field=models.CharField(blank=True, max_length=255, null=True),
+        ),
+        migrations.AddField(
+            model_name="orderlinediscount",
+            name="voucher_code",
+            field=models.CharField(blank=True, max_length=255, null=True),
+        ),
+    ]
diff --git a/saleor/discount/migrations/0062_basediscount_voucher_code_add_index.py b/saleor/discount/migrations/0062_basediscount_voucher_code_add_index.py
new file mode 100644
index 00000000000..ee9dd37cc5b
--- /dev/null
+++ b/saleor/discount/migrations/0062_basediscount_voucher_code_add_index.py
@@ -0,0 +1,34 @@
+# Generated by Django 3.2.21 on 2023-10-04 10:26
+
+from django.db import migrations
+from django.contrib.postgres.operations import AddIndexConcurrently
+from django.contrib.postgres.indexes import GinIndex
+
+
+class Migration(migrations.Migration):
+    atomic = False
+
+    dependencies = [
+        ("discount", "0061_basediscount_add_voucher_code"),
+    ]
+
+    operations = [
+        AddIndexConcurrently(
+            model_name="orderdiscount",
+            index=GinIndex(
+                fields=["voucher_code"], name="orderdiscount_voucher_code_idx"
+            ),
+        ),
+        AddIndexConcurrently(
+            model_name="checkoutlinediscount",
+            index=GinIndex(
+                fields=["voucher_code"], name="checklinedisc_voucher_code_idx"
+            ),
+        ),
+        AddIndexConcurrently(
+            model_name="orderlinediscount",
+            index=GinIndex(
+                fields=["voucher_code"], name="orderlinedisc_voucher_code_idx"
+            ),
+        ),
+    ]
diff --git a/saleor/discount/migrations/0063_basediscount_voucher_code.py b/saleor/discount/migrations/0063_basediscount_voucher_code.py
new file mode 100644
index 00000000000..5be50782124
--- /dev/null
+++ b/saleor/discount/migrations/0063_basediscount_voucher_code.py
@@ -0,0 +1,25 @@
+from django.db import migrations
+from .tasks.saleor3_17 import set_discounts_voucher_code_task
+from django.db.models.signals import post_migrate
+from django.apps import apps as registry
+
+
+def set_discounts_voucher_code(apps, schema_editor):
+    def on_migrations_complete(sender=None, **kwargs):
+        set_discounts_voucher_code_task.delay()
+
+    sender = registry.get_app_config("discount")
+    post_migrate.connect(on_migrations_complete, weak=False, sender=sender)
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("discount", "0062_basediscount_voucher_code_add_index"),
+    ]
+
+    operations = [
+        migrations.RunPython(
+            set_discounts_voucher_code,
+            migrations.RunPython.noop,
+        ),
+    ]
diff --git a/saleor/discount/migrations/tasks/saleor3_17.py b/saleor/discount/migrations/tasks/saleor3_17.py
index ab31c378081..13c9532f521 100644
--- a/saleor/discount/migrations/tasks/saleor3_17.py
+++ b/saleor/discount/migrations/tasks/saleor3_17.py
@@ -1,6 +1,15 @@
 from django.db.models import Exists, F, OuterRef
+from django.db import transaction
 
 from ....celeryconf import app
+from ....discount.models import (
+    Voucher,
+    VoucherCode,
+    VoucherCustomer,
+    CheckoutLineDiscount,
+    OrderDiscount,
+    OrderLineDiscount,
+)
 from ....product.models import (
     Product,
     ProductVariant,
@@ -10,7 +19,31 @@
 from ....product.utils.variant_prices import update_discounted_prices_for_promotion
 
 # For 100 rules, with 1000 variants for each rule it takes around 15s
-BATCH_SIZE = 100
+PRICE_RECALCULATION_BATCH_SIZE = 100
+
+# For 1 million vouchers it takes around 0.5s and consumes ~40MB memory at peak
+VOUCHER_BATCH_SIZE = 5000
+
+# The batch of size 1000 took about 0.2s
+VOUCHER_CUSTOMER_BATCH_SIZE = 1000
+
+
+# For batch size 1000 with 10_000 per model OrderDiscount/Voucher/VoucherCode objects
+# OrderDiscount Migration took 2.39 seconds.
+# OrderDiscount Memory usage increased by 15.65 MiB.
+# OrderLineDiscount Migration took 2.48 seconds.
+# OrderLineDiscount Memory usage increased by 4.61 MiB.
+# CheckoutLineDiscount Migration took 2.26 seconds.
+# CheckoutLineDiscount Memory usage increased by 3.23 MiB.
+
+# For batch size 1000 with 100_000 per model OrderDiscount/Voucher/VoucherCode objects
+# OrderDiscount Migration took 28.68 seconds.
+# OrderDiscount Memory usage increased by 163.00 MiB.
+# OrderLineDiscount Migration took 27.98 seconds.
+# OrderLineDiscount Memory usage increased by 47.99 MiB.
+# CheckoutLineDiscount Migration took 28.12 seconds.
+# CheckoutLineDiscount Memory usage increased by 35.52 MiB.
+BASE_DISCOUNT_BATCH_SIZE = 1000
 
 
 @app.task
@@ -33,8 +66,138 @@ def update_discounted_prices_task():
     )
     products_ids = Product.objects.filter(
         Exists(variant_qs.filter(product_id=OuterRef("id")))
-    ).values_list("id", flat=True)[:BATCH_SIZE]
+    ).values_list("id", flat=True)[:PRICE_RECALCULATION_BATCH_SIZE]
     if products_ids:
         products = Product.objects.filter(id__in=products_ids)
         update_discounted_prices_for_promotion(products)
         update_discounted_prices_task.delay()
+
+
+@app.task
+def move_codes_to_voucher_code_task():
+    vouchers = Voucher.objects.filter(
+        ~Exists(VoucherCode.objects.filter(voucher_id=OuterRef("id")))
+    )
+    if ids := vouchers.values_list("id", flat=True)[:VOUCHER_BATCH_SIZE]:
+        qs = Voucher.objects.filter(id__in=ids)
+        with transaction.atomic():
+            _vouchers = list(qs.select_for_update(of=(["self"])))
+            move_codes_to_voucher_code(qs)
+        move_codes_to_voucher_code_task.delay()
+
+
+def move_codes_to_voucher_code(vouchers):
+    voucher_codes = []
+
+    for voucher in vouchers.values("id", "code", "used"):
+        voucher_codes.append(
+            VoucherCode(
+                voucher_id=voucher["id"],
+                code=voucher["code"],
+                used=voucher["used"],
+            )
+        )
+    VoucherCode.objects.bulk_create(voucher_codes)
+
+
+@app.task
+def set_voucher_customer_codes_task():
+    voucher_customers = VoucherCustomer.objects.exclude(
+        Exists(VoucherCode.objects.filter(id=OuterRef("voucher_code_id")))
+    ).order_by("pk")[:VOUCHER_CUSTOMER_BATCH_SIZE]
+    if ids := list(voucher_customers.values_list("pk", flat=True)):
+        qs = VoucherCustomer.objects.filter(pk__in=ids)
+        with transaction.atomic():
+            _voucher_customers = list(qs.select_for_update(of=(["self"])))
+            set_voucher_code(qs)
+        set_voucher_customer_codes_task.delay()
+
+
+def set_voucher_code(voucher_customers):
+    voucher_id_to_code_map = get_voucher_id_to_code_map(voucher_customers)
+    voucher_customers_list = []
+    for voucher_customer in voucher_customers:
+        code = voucher_id_to_code_map[voucher_customer.voucher_id]
+        voucher_customer.voucher_code = code
+        voucher_customers_list.append(voucher_customer)
+    VoucherCustomer.objects.bulk_update(voucher_customers_list, ["voucher_code"])
+
+
+def get_voucher_id_to_code_map(voucher_customers):
+    voucher_id_to_code_map = {}
+    vouchers = Voucher.objects.filter(
+        Exists(voucher_customers.filter(voucher_id=OuterRef("pk")))
+    )
+    codes = VoucherCode.objects.filter(
+        Exists(vouchers.filter(id=OuterRef("voucher_id")))
+    )
+    for code in codes:
+        voucher_id_to_code_map[code.voucher_id] = code
+
+    return voucher_id_to_code_map
+
+
+@app.task
+def set_discounts_voucher_code_task():
+    set_order_discount_voucher_code_task.delay()
+    set_order_line_discount_voucher_code_task.delay()
+    set_checkout_line_discount_voucher_code_task.delay()
+
+
+@app.task
+def set_order_discount_voucher_code_task() -> None:
+    model_discounts = OrderDiscount.objects.filter(
+        voucher__isnull=False, voucher_code__isnull=True
+    ).order_by("pk")[:BASE_DISCOUNT_BATCH_SIZE]
+    if ids := list(model_discounts.values_list("pk", flat=True)):
+        qs = OrderDiscount.objects.filter(pk__in=ids)
+        with transaction.atomic():
+            _discounts = list(qs.select_for_update(of=(["self"])))
+            set_discount_voucher_code(OrderDiscount, qs)
+        set_order_discount_voucher_code_task.delay()
+
+
+@app.task
+def set_order_line_discount_voucher_code_task() -> None:
+    model_discounts = OrderLineDiscount.objects.filter(
+        voucher__isnull=False, voucher_code__isnull=True
+    ).order_by("pk")[:BASE_DISCOUNT_BATCH_SIZE]
+    if ids := list(model_discounts.values_list("pk", flat=True)):
+        qs = OrderLineDiscount.objects.filter(pk__in=ids)
+        with transaction.atomic():
+            _discounts = list(qs.select_for_update(of=(["self"])))
+            set_discount_voucher_code(OrderLineDiscount, qs)
+        set_order_line_discount_voucher_code_task.delay()
+
+
+@app.task
+def set_checkout_line_discount_voucher_code_task() -> None:
+    model_discounts = CheckoutLineDiscount.objects.filter(
+        voucher__isnull=False, voucher_code__isnull=True
+    ).order_by("pk")[:BASE_DISCOUNT_BATCH_SIZE]
+    if ids := list(model_discounts.values_list("pk", flat=True)):
+        qs = CheckoutLineDiscount.objects.filter(pk__in=ids)
+        with transaction.atomic():
+            _discounts = list(qs.select_for_update(of=(["self"])))
+            set_discount_voucher_code(CheckoutLineDiscount, qs)
+        set_checkout_line_discount_voucher_code_task.delay()
+
+
+def set_discount_voucher_code(ModelDiscount, model_discounts) -> None:
+    voucher_id_to_code_map = get_discount_voucher_id_to_code_map(model_discounts)
+    model_discounts_list = []
+    for model_discount in model_discounts:
+        code = voucher_id_to_code_map[model_discount.voucher_id]
+        model_discount.voucher_code = code
+        model_discounts_list.append(model_discount)
+    ModelDiscount.objects.bulk_update(model_discounts_list, ["voucher_code"])
+
+
+def get_discount_voucher_id_to_code_map(model_discounts):
+    vouchers = Voucher.objects.filter(
+        Exists(model_discounts.filter(voucher_id=OuterRef("pk")))
+    )
+    voucher_id_to_code_map = {
+        voucher_id: code for voucher_id, code in vouchers.values_list("id", "code")
+    }
+    return voucher_id_to_code_map
diff --git a/saleor/discount/models.py b/saleor/discount/models.py
index 363f5267bce..eaa5b43cc11 100644
--- a/saleor/discount/models.py
+++ b/saleor/discount/models.py
@@ -180,6 +180,20 @@ def validate_only_for_staff(self, customer: Optional["User"]):
             raise NotApplicable(msg)
 
 
+class VoucherCode(models.Model):
+    id = models.UUIDField(primary_key=True, editable=False, unique=True, default=uuid4)
+    code = models.CharField(max_length=255, unique=True, db_index=True)
+    used = models.PositiveIntegerField(default=0)
+    is_active = models.BooleanField(default=True)
+    voucher = models.ForeignKey(
+        Voucher, related_name="codes", on_delete=models.CASCADE, db_index=False
+    )
+
+    class Meta:
+        indexes = [BTreeIndex(fields=["voucher"], name="vouchercode_voucher_idx")]
+        ordering = ("code",)
+
+
 class VoucherChannelListing(models.Model):
     voucher = models.ForeignKey(
         Voucher,
@@ -220,9 +234,19 @@ class VoucherCustomer(models.Model):
     voucher = models.ForeignKey(
         Voucher, related_name="customers", on_delete=models.CASCADE
     )
+    voucher_code = models.ForeignKey(
+        VoucherCode,
+        related_name="customers",
+        on_delete=models.CASCADE,
+        db_index=False,
+        null=True,
+    )
     customer_email = models.EmailField()
 
     class Meta:
+        indexes = [
+            BTreeIndex(fields=["voucher_code"], name="vouchercustomer_voucher_code_idx")
+        ]
         ordering = ("voucher", "customer_email", "pk")
         unique_together = (("voucher", "customer_email"),)
 
@@ -411,6 +435,9 @@ class BaseDiscount(models.Model):
     voucher = models.ForeignKey(
         Voucher, related_name="+", blank=True, null=True, on_delete=models.SET_NULL
     )
+    voucher_code = models.CharField(
+        max_length=255, null=True, blank=True, db_index=False
+    )
 
     class Meta:
         abstract = True
@@ -433,6 +460,7 @@ class Meta:
             ),
             # Orders searching index
             GinIndex(fields=["name", "translated_name"]),
+            GinIndex(fields=["voucher_code"], name="orderdiscount_voucher_code_idx"),
         ]
         ordering = ("created_at", "id")
 
@@ -450,7 +478,8 @@ class Meta:
         indexes = [
             BTreeIndex(
                 fields=["promotion_rule"], name="orderlinedisc_promotion_rule_idx"
-            )
+            ),
+            GinIndex(fields=["voucher_code"], name="orderlinedisc_voucher_code_idx"),
         ]
         ordering = ("created_at", "id")
 
@@ -468,7 +497,8 @@ class Meta:
         indexes = [
             BTreeIndex(
                 fields=["promotion_rule"], name="checklinedisc_promotion_rule_idx"
-            )
+            ),
+            GinIndex(fields=["voucher_code"], name="checklinedisc_voucher_code_idx"),
         ]
         ordering = ("created_at", "id")
 
diff --git a/saleor/discount/tests/test_discounts.py b/saleor/discount/tests/test_discounts.py
index 721b7ea634f..41a797230a2 100644
--- a/saleor/discount/tests/test_discounts.py
+++ b/saleor/discount/tests/test_discounts.py
@@ -182,23 +182,33 @@ def test_voucher_queryset_active_in_other_channel(voucher, channel_PLN):
 
 
 def test_increase_voucher_usage(channel_USD):
+    # given
     voucher = Voucher.objects.create(
         code="unique",
         type=VoucherType.ENTIRE_ORDER,
         discount_value_type=DiscountValueType.FIXED,
         usage_limit=100,
     )
+    code = voucher.codes.create(code=voucher.code, used=voucher.used)
     VoucherChannelListing.objects.create(
         voucher=voucher,
         channel=channel_USD,
         discount=Money(10, channel_USD.currency_code),
     )
-    increase_voucher_usage(voucher)
+
+    # when
+    increase_voucher_usage(voucher, code)
+
+    # then
     voucher.refresh_from_db()
     assert voucher.used == 1
 
+    code.refresh_from_db()
+    assert code.used == 1
+
 
 def test_decrease_voucher_usage(channel_USD):
+    # given
     voucher = Voucher.objects.create(
         code="unique",
         type=VoucherType.ENTIRE_ORDER,
@@ -206,30 +216,46 @@ def test_decrease_voucher_usage(channel_USD):
         usage_limit=100,
         used=10,
     )
+    code = voucher.codes.create(code=voucher.code, used=voucher.used)
     VoucherChannelListing.objects.create(
         voucher=voucher,
         channel=channel_USD,
         discount=Money(10, channel_USD.currency_code),
     )
-    decrease_voucher_usage(voucher)
+
+    # when
+    decrease_voucher_usage(voucher, code)
+
+    # then
     voucher.refresh_from_db()
     assert voucher.used == 9
 
+    code.refresh_from_db()
+    assert code.used == voucher.used
+
 
 def test_add_voucher_usage_by_customer(voucher, customer_user):
+    # given
     voucher_customer_count = VoucherCustomer.objects.all().count()
-    add_voucher_usage_by_customer(voucher, customer_user.email)
+    code = voucher.codes.first()
+
+    # when
+    add_voucher_usage_by_customer(voucher, code, customer_user.email)
+
+    # then
     assert VoucherCustomer.objects.all().count() == voucher_customer_count + 1
     voucherCustomer = VoucherCustomer.objects.first()
     assert voucherCustomer.voucher == voucher
+    assert voucherCustomer.voucher_code == code
     assert voucherCustomer.customer_email == customer_user.email
 
 
 def test_add_voucher_usage_by_customer_raise_not_applicable(voucher_customer):
     voucher = voucher_customer.voucher
+    code = voucher.codes.first()
     customer_email = voucher_customer.customer_email
     with pytest.raises(NotApplicable):
-        add_voucher_usage_by_customer(voucher, customer_email)
+        add_voucher_usage_by_customer(voucher, code, customer_email)
 
 
 def test_remove_voucher_usage_by_customer(voucher_customer):
diff --git a/saleor/discount/utils.py b/saleor/discount/utils.py
index 479db3cab4a..64c7e475ca1 100644
--- a/saleor/discount/utils.py
+++ b/saleor/discount/utils.py
@@ -45,28 +45,40 @@
         ProductVariantChannelListing,
         VariantChannelListingPromotionRule,
     )
-    from .models import Voucher
+    from .models import Voucher, VoucherCode
 
 CatalogueInfo = DefaultDict[str, Set[Union[int, str]]]
 CATALOGUE_FIELDS = ["categories", "collections", "products", "variants"]
 
 
-def increase_voucher_usage(voucher: "Voucher") -> None:
+def increase_voucher_usage(voucher: "Voucher", code: "VoucherCode") -> None:
     """Increase voucher uses by 1."""
     voucher.used = F("used") + 1
     voucher.save(update_fields=["used"])
 
+    code.used = F("used") + 1
+    code.save(update_fields=["used"])
 
-def decrease_voucher_usage(voucher: "Voucher") -> None:
+
+def decrease_voucher_usage(voucher: "Voucher", code: "VoucherCode") -> None:
     """Decrease voucher uses by 1."""
     voucher.used = F("used") - 1
     voucher.save(update_fields=["used"])
 
+    code.used = F("used") - 1
+    code.save(update_fields=["used"])
+
 
-def add_voucher_usage_by_customer(voucher: "Voucher", customer_email: str) -> None:
-    _, created = VoucherCustomer.objects.get_or_create(
+def add_voucher_usage_by_customer(
+    voucher: "Voucher", code: "VoucherCode", customer_email: str
+) -> None:
+    voucher_customer, created = VoucherCustomer.objects.get_or_create(
         voucher=voucher, customer_email=customer_email
     )
+    if not voucher_customer.voucher_code:
+        voucher_customer.voucher_code = code
+        voucher_customer.save(update_fields=["voucher_code"])
+
     if not created:
         raise NotApplicable("This offer is only valid once per customer.")
 
@@ -79,11 +91,16 @@ def remove_voucher_usage_by_customer(voucher: "Voucher", customer_email: str) ->
         voucher_customer.delete()
 
 
-def release_voucher_usage(voucher: Optional["Voucher"], user_email: Optional[str]):
+def release_voucher_usage(
+    voucher: Optional["Voucher"],
+    code: Optional["VoucherCode"],
+    user_email: Optional[str],
+):
     if not voucher:
         return
     if voucher.usage_limit:
-        decrease_voucher_usage(voucher)
+        # type-ignore added as we ensure that voucher code instance is created
+        decrease_voucher_usage(voucher, code)  # type: ignore[arg-type]
     if user_email:
         remove_voucher_usage_by_customer(voucher, user_email)
 
diff --git a/saleor/graphql/checkout/dataloaders.py b/saleor/graphql/checkout/dataloaders.py
index 4769e92f655..482b0041ed4 100644
--- a/saleor/graphql/checkout/dataloaders.py
+++ b/saleor/graphql/checkout/dataloaders.py
@@ -35,6 +35,7 @@
     PromotionByRuleIdLoader,
     PromotionRuleByIdLoader,
     VoucherByCodeLoader,
+    VoucherCodeByCodeLoader,
     VoucherInfoByVoucherCodeLoader,
 )
 from ..plugins.dataloaders import get_plugin_manager_promise
@@ -468,6 +469,9 @@ def with_channel(channels):
                     if checkout.voucher_code
                 }
                 vouchers = VoucherByCodeLoader(self.context).load_many(voucher_codes)
+                voucher_codes = VoucherCodeByCodeLoader(self.context).load_many(
+                    voucher_codes
+                )
                 channel_ids = [channel.id for channel in channels]
                 tax_configurations = TaxConfigurationByChannelId(
                     self.context
@@ -481,6 +485,7 @@ def with_checkout_info(results):
                         listings_for_channels,
                         collection_points,
                         vouchers,
+                        voucher_codes,
                         tax_configurations,
                     ) = results
                     address_map = {address.id: address for address in addresses}
@@ -494,6 +499,7 @@ def with_checkout_info(results):
                         for collection_point in collection_points
                     }
                     voucher_map = {voucher.code: voucher for voucher in vouchers}
+                    voucher_code_map = {code.code: code for code in voucher_codes}
                     tax_configuration_by_channel_map = {
                         tax_configuration.channel_id: tax_configuration
                         for tax_configuration in tax_configurations
@@ -514,6 +520,7 @@ def with_checkout_info(results):
                             None, shipping_address
                         )
                         voucher = voucher_map.get(checkout.voucher_code)
+                        voucher_code = voucher_code_map.get(checkout.voucher_code)
                         checkout_info = CheckoutInfo(
                             checkout=checkout,
                             user=user_map.get(checkout.user_id),
@@ -531,6 +538,7 @@ def with_checkout_info(results):
                             valid_pick_up_points=[],
                             all_shipping_methods=[],
                             voucher=voucher,
+                            voucher_code=voucher_code,
                         )
                         shipping_method_listings = [
                             listing
@@ -559,6 +567,7 @@ def with_checkout_info(results):
                         shipping_method_channel_listings,
                         collection_points,
                         vouchers,
+                        voucher_codes,
                         tax_configurations,
                     ]
                 ).then(with_checkout_info)
diff --git a/saleor/graphql/checkout/tests/mutations/test_checkout_complete_with_payment.py b/saleor/graphql/checkout/tests/mutations/test_checkout_complete_with_payment.py
index ee6153b13dc..1abb4bc9fd1 100644
--- a/saleor/graphql/checkout/tests/mutations/test_checkout_complete_with_payment.py
+++ b/saleor/graphql/checkout/tests/mutations/test_checkout_complete_with_payment.py
@@ -825,6 +825,8 @@ def test_checkout_with_voucher_complete(
     voucher_percentage.usage_limit = voucher_used_count + 1
     voucher_percentage.save(update_fields=["usage_limit"])
 
+    code = voucher_percentage.codes.first()
+
     checkout = checkout_with_voucher_percentage
     checkout.shipping_address = address
     checkout.shipping_method = shipping_method
@@ -896,12 +898,16 @@ def test_checkout_with_voucher_complete(
 
     voucher_percentage.refresh_from_db()
     assert voucher_percentage.used == voucher_used_count + 1
+    code.refresh_from_db()
+    assert code.used == voucher_used_count + 1
     order_discount = order.discounts.filter(type=DiscountType.VOUCHER).first()
     assert order_discount
     assert (
         order_discount.amount_value
         == (order.undiscounted_total - order.total).gross.amount
     )
+    assert order.voucher == voucher_percentage
+    assert order.voucher.code == code.code
 
     assert not Checkout.objects.filter(
         pk=checkout.pk
@@ -923,6 +929,8 @@ def test_checkout_complete_with_voucher_apply_once_per_order(
     voucher_percentage.apply_once_per_order = True
     voucher_percentage.save(update_fields=["usage_limit", "apply_once_per_order"])
 
+    code = voucher_percentage.codes.first()
+
     checkout = checkout_with_voucher_percentage
 
     checkout_line = checkout.lines.first()
@@ -1000,12 +1008,16 @@ def test_checkout_complete_with_voucher_apply_once_per_order(
 
     voucher_percentage.refresh_from_db()
     assert voucher_percentage.used == voucher_used_count + 1
+    code.refresh_from_db()
+    assert code.used == voucher_used_count + 1
     order_discount = order.discounts.filter(type=DiscountType.VOUCHER).first()
     assert order_discount
     assert (
         order_discount.amount_value
         == (order.undiscounted_total - order.total).gross.amount
     )
+    assert order.voucher == voucher_percentage
+    assert order.voucher.code == code.code
 
     assert not Checkout.objects.filter(
         pk=checkout.pk
@@ -1026,6 +1038,8 @@ def test_checkout_with_voucher_complete_product_on_promotion(
     voucher_percentage.usage_limit = voucher_used_count + 1
     voucher_percentage.save(update_fields=["usage_limit"])
 
+    code = voucher_percentage.codes.first()
+
     checkout = checkout_with_voucher_percentage
     checkout.shipping_address = address
     checkout.shipping_method = shipping_method
@@ -1152,6 +1166,10 @@ def test_checkout_with_voucher_complete_product_on_promotion(
 
     voucher_percentage.refresh_from_db()
     assert voucher_percentage.used == voucher_used_count + 1
+    code.refresh_from_db()
+    assert code.used == voucher_used_count + 1
+    assert order.voucher == voucher_percentage
+    assert order.voucher.code == code.code
 
     assert not Checkout.objects.filter(
         pk=checkout.pk
@@ -1249,6 +1267,10 @@ def test_checkout_with_voucher_on_specific_product_complete(
 
     voucher_specific_product_type.refresh_from_db()
     assert voucher_specific_product_type.used == voucher_used_count + 1
+    code = voucher_specific_product_type.codes.first()
+    assert code.used == voucher_used_count + 1
+    assert order.voucher == voucher_specific_product_type
+    assert order.voucher.code == code.code
 
     assert not Checkout.objects.filter(
         pk=checkout.pk
@@ -1856,6 +1878,10 @@ def test_checkout_with_voucher_on_specific_product_complete_with_product_on_prom
 
     voucher_specific_product_type.refresh_from_db()
     assert voucher_specific_product_type.used == voucher_used_count + 1
+    code = voucher_specific_product_type.codes.first()
+    assert code.used == voucher_used_count + 1
+    assert order.voucher == voucher_specific_product_type
+    assert order.voucher.code == code.code
 
     assert not Checkout.objects.filter(
         pk=checkout.pk
@@ -1904,6 +1930,8 @@ def test_checkout_with_voucher_not_increase_uses_on_preprocess_order_creation_fa
 
     voucher_percentage.refresh_from_db()
     assert voucher_percentage.used == 0
+    code = voucher_percentage.codes.first()
+    assert code.used == 0
 
     assert Checkout.objects.filter(
         pk=checkout.pk
@@ -2135,6 +2163,7 @@ def test_checkout_complete_does_not_delete_checkout_after_unsuccessful_payment(
 ):
     mocked_process_payment.side_effect = error_side_effect
     expected_voucher_usage_count = voucher.used
+    code = voucher.codes.first()
     checkout = checkout_with_voucher
     checkout.shipping_address = address
     checkout.shipping_method = shipping_method
@@ -2174,6 +2203,7 @@ def test_checkout_complete_does_not_delete_checkout_after_unsuccessful_payment(
     # ensure the voucher usage count was not incremented
     voucher.refresh_from_db(fields=["used"])
     assert voucher.used == expected_voucher_usage_count
+    assert code.used == expected_voucher_usage_count
 
     assert Checkout.objects.filter(
         pk=checkout.pk
diff --git a/saleor/graphql/checkout/tests/mutations/test_checkout_complete_with_transactions.py b/saleor/graphql/checkout/tests/mutations/test_checkout_complete_with_transactions.py
index 16b24e2d26f..0265cc5655e 100644
--- a/saleor/graphql/checkout/tests/mutations/test_checkout_complete_with_transactions.py
+++ b/saleor/graphql/checkout/tests/mutations/test_checkout_complete_with_transactions.py
@@ -1601,12 +1601,17 @@ def test_checkout_with_voucher_complete(
 
     voucher_percentage.refresh_from_db()
     assert voucher_percentage.used == voucher_used_count + 1
+    code = voucher_percentage.codes.first()
+    assert code.used == voucher_used_count + 1
+
     order_discount = order.discounts.filter(type=DiscountType.VOUCHER).first()
     assert order_discount
     assert (
         order_discount.amount_value
         == (order.undiscounted_total - order.total).gross.amount
     )
+    assert order.voucher == voucher_percentage
+    assert order.voucher.code == code.code
 
     assert not Checkout.objects.filter(
         pk=checkout.pk
@@ -1683,6 +1688,11 @@ def test_checkout_complete_with_voucher_apply_once_per_order(
 
     voucher_percentage.refresh_from_db()
     assert voucher_percentage.used == voucher_used_count + 1
+    code = voucher_percentage.codes.first()
+    assert code.used == voucher_used_count + 1
+    assert order.voucher == voucher_percentage
+    assert order.voucher.code == code.code
+
     order_discount = order.discounts.filter(type=DiscountType.VOUCHER).first()
     assert order_discount
     assert (
@@ -1811,6 +1821,10 @@ def test_checkout_with_voucher_complete_product_on_sale(
 
     voucher_percentage.refresh_from_db()
     assert voucher_percentage.used == voucher_used_count + 1
+    code = voucher_percentage.codes.first()
+    assert code.used == voucher_used_count + 1
+    assert order.voucher == voucher_percentage
+    assert order.voucher.code == code.code
 
     assert not Checkout.objects.filter(
         pk=checkout.pk
@@ -1884,6 +1898,10 @@ def test_checkout_with_voucher_on_specific_product_complete(
 
     voucher_specific_product_type.refresh_from_db()
     assert voucher_specific_product_type.used == voucher_used_count + 1
+    code = voucher_specific_product_type.codes.first()
+    assert code.used == voucher_used_count + 1
+    assert order.voucher == voucher_specific_product_type
+    assert order.voucher.code == code.code
 
     assert not Checkout.objects.filter(
         pk=checkout.pk
@@ -2227,6 +2245,8 @@ def test_checkout_with_voucher_on_specific_product_complete_with_product_on_prom
 
     voucher_specific_product_type.refresh_from_db()
     assert voucher_specific_product_type.used == voucher_used_count + 1
+    code = voucher_specific_product_type.codes.first()
+    assert code.used == voucher_used_count + 1
 
     assert not Checkout.objects.filter(
         pk=checkout.pk
@@ -2275,6 +2295,8 @@ def test_checkout_with_voucher_not_increase_uses_on_preprocess_order_creation_fa
 
     voucher_percentage.refresh_from_db()
     assert voucher_percentage.used == 0
+    code = voucher_percentage.codes.first()
+    assert code.used == 0
 
     assert Checkout.objects.filter(
         pk=checkout.pk
diff --git a/saleor/graphql/discount/dataloaders.py b/saleor/graphql/discount/dataloaders.py
index e3db6838607..9e8a7b38568 100644
--- a/saleor/graphql/discount/dataloaders.py
+++ b/saleor/graphql/discount/dataloaders.py
@@ -14,6 +14,7 @@
     PromotionRule,
     Voucher,
     VoucherChannelListing,
+    VoucherCode,
 )
 from ..channel.dataloaders import ChannelBySlugLoader
 from ..core.dataloaders import DataLoader
@@ -39,6 +40,21 @@ def batch_load(self, keys):
         return [vouchers.get(code) for code in keys]
 
 
+class VoucherCodeByCodeLoader(DataLoader):
+    context_key = "voucher_code_by_code"
+
+    def batch_load(self, keys):
+        voucher_codes = (
+            VoucherCode.objects.using(self.database_connection_name)
+            .select_related("voucher")
+            .filter(code__in=keys)
+        )
+        voucher_map = {
+            voucher_code.code: voucher_code for voucher_code in voucher_codes
+        }
+        return [voucher_map.get(code) for code in keys]
+
+
 class VoucherChannelListingByVoucherIdAndChanneSlugLoader(DataLoader):
     context_key = "voucherchannelisting_by_voucher_and_channel"
 
diff --git a/saleor/graphql/discount/mutations/voucher/voucher_create.py b/saleor/graphql/discount/mutations/voucher/voucher_create.py
index 1d3b3b3ed71..2c9cc465e00 100644
--- a/saleor/graphql/discount/mutations/voucher/voucher_create.py
+++ b/saleor/graphql/discount/mutations/voucher/voucher_create.py
@@ -1,5 +1,6 @@
 import graphene
 from django.core.exceptions import ValidationError
+from django.db import transaction
 
 from .....core.utils.promo_code import generate_promo_code, is_available_promo_code
 from .....discount import models
@@ -96,6 +97,24 @@ class Meta:
             )
         ]
 
+    @classmethod
+    def perform_mutation(cls, _root, info: ResolveInfo, /, **data):
+        voucher_instance = cls.get_instance(info, **data)
+        data = data.get("input")
+        cleaned_input = cls.clean_input(info, voucher_instance, data)
+        code = cleaned_input.get("code")
+
+        voucher_instance = cls.construct_instance(voucher_instance, cleaned_input)
+        code_instance = cls.create_code_instance(code, voucher_instance)
+
+        cls.clean_instance(info, voucher_instance)
+        cls.clean_code_instance(code_instance)
+
+        cls.save(info, voucher_instance, code_instance)
+
+        cls.post_save_action(info, voucher_instance, cleaned_input)
+        return cls.success_response(voucher_instance)
+
     @classmethod
     def clean_input(cls, info: ResolveInfo, instance, data, **kwargs):
         code = data.get("code", None)
@@ -115,9 +134,11 @@ def clean_input(cls, info: ResolveInfo, instance, data, **kwargs):
         return cleaned_input
 
     @classmethod
-    def success_response(cls, instance):
-        instance = ChannelContext(node=instance, channel_slug=None)
-        return super().success_response(instance)
+    def create_code_instance(cls, code, voucher):
+        return models.VoucherCode(
+            code=code,
+            voucher=voucher,
+        )
 
     @classmethod
     def clean_instance(cls, info: ResolveInfo, instance):
@@ -130,7 +151,27 @@ def clean_instance(cls, info: ResolveInfo, instance):
             error.code = DiscountErrorCode.INVALID.value
             raise ValidationError({"end_date": error})
 
+    @classmethod
+    def clean_code_instance(cls, code_instance):
+        code_instance.full_clean(exclude=["voucher"])
+
+    @classmethod
+    def save(
+        cls,
+        _info: ResolveInfo,
+        voucher_instance,
+        code_instance,
+    ):
+        with transaction.atomic():
+            voucher_instance.save()
+            code_instance.save()
+
     @classmethod
     def post_save_action(cls, info: ResolveInfo, instance, cleaned_input):
         manager = get_plugin_manager_promise(info.context).get()
         cls.call_event(manager.voucher_created, instance)
+
+    @classmethod
+    def success_response(cls, instance):
+        instance = ChannelContext(node=instance, channel_slug=None)
+        return super().success_response(instance)
diff --git a/saleor/graphql/discount/mutations/voucher/voucher_update.py b/saleor/graphql/discount/mutations/voucher/voucher_update.py
index ef550f40341..ece889d020e 100644
--- a/saleor/graphql/discount/mutations/voucher/voucher_update.py
+++ b/saleor/graphql/discount/mutations/voucher/voucher_update.py
@@ -32,6 +32,20 @@ class Meta:
             )
         ]
 
+    @classmethod
+    def create_code_instance(cls, code, voucher):
+        if not code:
+            return
+        code_instance = voucher.codes.first()
+        if code_instance:
+            code_instance.code = code
+        else:
+            code_instance = models.VoucherCode(
+                code=code,
+                voucher=voucher,
+            )
+        return code_instance
+
     @classmethod
     def post_save_action(cls, info: ResolveInfo, instance, cleaned_input):
         manager = get_plugin_manager_promise(info.context).get()
diff --git a/saleor/graphql/discount/tests/mutations/test_voucher_create.py b/saleor/graphql/discount/tests/mutations/test_voucher_create.py
index 85b1d18aed3..b25c39e0b03 100644
--- a/saleor/graphql/discount/tests/mutations/test_voucher_create.py
+++ b/saleor/graphql/discount/tests/mutations/test_voucher_create.py
@@ -51,12 +51,14 @@
 
 
 def test_create_voucher(staff_api_client, permission_manage_discounts):
+    # given
     start_date = timezone.now() - timedelta(days=365)
     end_date = timezone.now() + timedelta(days=365)
+    code = "testcode123"
     variables = {
         "name": "test voucher",
         "type": VoucherTypeEnum.ENTIRE_ORDER.name,
-        "code": "testcode123",
+        "code": code,
         "discountValueType": DiscountValueTypeEnum.FIXED.name,
         "minCheckoutItemsQuantity": 10,
         "startDate": start_date.isoformat(),
@@ -66,20 +68,25 @@ def test_create_voucher(staff_api_client, permission_manage_discounts):
         "usageLimit": 3,
     }
 
+    # when
     response = staff_api_client.post_graphql(
         CREATE_VOUCHER_MUTATION, variables, permissions=[permission_manage_discounts]
     )
+
+    # then
     get_graphql_content(response)
     voucher = Voucher.objects.get()
     assert voucher.type == VoucherType.ENTIRE_ORDER
     assert voucher.name == "test voucher"
-    assert voucher.code == "testcode123"
+    assert voucher.code == code
     assert voucher.discount_value_type == DiscountValueType.FIXED
     assert voucher.start_date == start_date
     assert voucher.end_date == end_date
     assert voucher.apply_once_per_order
     assert voucher.apply_once_per_customer
     assert voucher.usage_limit == 3
+    assert voucher.codes.count() == 1
+    assert voucher.codes.first().code == code
 
 
 @freeze_time("2022-05-12 12:00:00")
diff --git a/saleor/graphql/discount/tests/mutations/test_voucher_update.py b/saleor/graphql/discount/tests/mutations/test_voucher_update.py
index 903528c3d1b..d12daee639f 100644
--- a/saleor/graphql/discount/tests/mutations/test_voucher_update.py
+++ b/saleor/graphql/discount/tests/mutations/test_voucher_update.py
@@ -38,28 +38,70 @@
 
 
 def test_update_voucher(staff_api_client, voucher, permission_manage_discounts):
+    # given
     apply_once_per_order = not voucher.apply_once_per_order
     # Set discount value type to 'fixed' and change it in mutation
     voucher.discount_value_type = DiscountValueType.FIXED
     voucher.save()
-    assert voucher.code != "testcode123"
+    new_code = "testcode123"
+    code_instance = voucher.codes.first()
+    assert voucher.code != new_code
     variables = {
         "id": graphene.Node.to_global_id("Voucher", voucher.id),
-        "code": "testcode123",
+        "code": new_code,
         "discountValueType": DiscountValueTypeEnum.PERCENTAGE.name,
         "applyOncePerOrder": apply_once_per_order,
         "minCheckoutItemsQuantity": 10,
     }
 
+    # when
     response = staff_api_client.post_graphql(
         UPDATE_VOUCHER_MUTATION, variables, permissions=[permission_manage_discounts]
     )
+
+    # then
+    content = get_graphql_content(response)
+    data = content["data"]["voucherUpdate"]["voucher"]
+    assert data["code"] == new_code
+    assert data["discountValueType"] == DiscountValueType.PERCENTAGE.upper()
+    assert data["applyOncePerOrder"] == apply_once_per_order
+    assert data["minCheckoutItemsQuantity"] == 10
+    code_instance.refresh_from_db()
+    assert code_instance.code == new_code
+
+
+def test_update_voucher_lack_of_code_instance(
+    staff_api_client, voucher, permission_manage_discounts
+):
+    # given
+    apply_once_per_order = not voucher.apply_once_per_order
+    # Set discount value type to 'fixed' and change it in mutation
+    voucher.discount_value_type = DiscountValueType.FIXED
+    voucher.save()
+    new_code = "testcode123"
+    assert voucher.code != new_code
+    variables = {
+        "id": graphene.Node.to_global_id("Voucher", voucher.id),
+        "code": new_code,
+        "discountValueType": DiscountValueTypeEnum.PERCENTAGE.name,
+        "applyOncePerOrder": apply_once_per_order,
+        "minCheckoutItemsQuantity": 10,
+    }
+
+    # when
+    response = staff_api_client.post_graphql(
+        UPDATE_VOUCHER_MUTATION, variables, permissions=[permission_manage_discounts]
+    )
+
+    # then
     content = get_graphql_content(response)
     data = content["data"]["voucherUpdate"]["voucher"]
-    assert data["code"] == "testcode123"
+    assert data["code"] == new_code
     assert data["discountValueType"] == DiscountValueType.PERCENTAGE.upper()
     assert data["applyOncePerOrder"] == apply_once_per_order
     assert data["minCheckoutItemsQuantity"] == 10
+    assert voucher.codes.count() == 1
+    assert voucher.codes.first().code == new_code
 
 
 @freeze_time("2022-05-12 12:00:00")
diff --git a/saleor/graphql/order/bulk_mutations/order_bulk_create.py b/saleor/graphql/order/bulk_mutations/order_bulk_create.py
index 22e8a3885fb..14720014e22 100644
--- a/saleor/graphql/order/bulk_mutations/order_bulk_create.py
+++ b/saleor/graphql/order/bulk_mutations/order_bulk_create.py
@@ -1020,7 +1020,8 @@ def get_instances_related_to_order(
         order_data.billing_address = billing_address
         order_data.shipping_address = shipping_address
         order_data.voucher = voucher
-
+        if order_data.voucher:
+            order_data.order.voucher_code = order_input.get("voucher")  # type: ignore
         if not channel or not billing_address:
             order_data.is_critical_error = True
 
diff --git a/saleor/graphql/order/mutations/draft_order_create.py b/saleor/graphql/order/mutations/draft_order_create.py
index f91e7addc8f..55692508fe9 100644
--- a/saleor/graphql/order/mutations/draft_order_create.py
+++ b/saleor/graphql/order/mutations/draft_order_create.py
@@ -197,9 +197,8 @@ def clean_input(cls, info: ResolveInfo, instance, data, **kwargs):
         cleaned_input.update(shipping_method_input)
         channel = cls.clean_channel_id(info, instance, cleaned_input, channel_id)
 
-        voucher = cleaned_input.get("voucher", None)
-        if voucher:
-            cls.clean_voucher(voucher, channel)
+        if "voucher" in cleaned_input:
+            cls.clean_voucher(cleaned_input, channel)
 
         if channel:
             cleaned_input["currency"] = channel.currency_code
@@ -240,7 +239,12 @@ def clean_channel_id(cls, info: ResolveInfo, instance, cleaned_input, channel_id
             return instance.channel if hasattr(instance, "channel") else None
 
     @classmethod
-    def clean_voucher(cls, voucher, channel):
+    def clean_voucher(cls, cleaned_input, channel):
+        voucher = cleaned_input.get("voucher")
+        if not voucher:
+            # We need to clean voucher_code as well
+            cleaned_input["voucher_code"] = None
+            return
         if not voucher.channel_listings.filter(channel=channel).exists():
             raise ValidationError(
                 {
@@ -250,6 +254,7 @@ def clean_voucher(cls, voucher, channel):
                     )
                 }
             )
+        cleaned_input["voucher_code"] = voucher.code
 
     @classmethod
     def clean_lines(cls, cleaned_input, lines, channel):
diff --git a/saleor/graphql/order/tests/mutations/test_draft_order_create.py b/saleor/graphql/order/tests/mutations/test_draft_order_create.py
index 679b0f6c3b5..9daf2f3d064 100644
--- a/saleor/graphql/order/tests/mutations/test_draft_order_create.py
+++ b/saleor/graphql/order/tests/mutations/test_draft_order_create.py
@@ -225,6 +225,8 @@ def test_draft_order_create(
         channel_id=order.channel_id
     ).get_total()
     assert order.base_shipping_price == shipping_total
+    assert order.voucher == voucher
+    assert order.voucher_code == voucher.code
 
     # Ensure the correct event was created
     created_draft_event = OrderEvent.objects.get(
diff --git a/saleor/graphql/order/tests/mutations/test_draft_order_update.py b/saleor/graphql/order/tests/mutations/test_draft_order_update.py
index 41e3b2199f1..89ccabaf2b7 100644
--- a/saleor/graphql/order/tests/mutations/test_draft_order_update.py
+++ b/saleor/graphql/order/tests/mutations/test_draft_order_update.py
@@ -238,6 +238,7 @@ def test_draft_order_update_clear_voucher(
     assert not data["errors"]
     order.refresh_from_db()
     assert not order.voucher
+    assert not order.voucher_code
     assert order.search_vector
 
     assert not order.discounts.count()
diff --git a/saleor/graphql/order/tests/mutations/test_order_bulk_create.py b/saleor/graphql/order/tests/mutations/test_order_bulk_create.py
index 2b6182351e8..506d65e1796 100644
--- a/saleor/graphql/order/tests/mutations/test_order_bulk_create.py
+++ b/saleor/graphql/order/tests/mutations/test_order_bulk_create.py
@@ -396,7 +396,7 @@ def order_bulk_input(
         "invoices": [invoice],
         "discounts": [discount],
         "giftCards": ["never_expiry"],
-        "voucher": "mirumee",
+        "voucher": voucher_without_channel.code,
         "metadata": [{"key": "md key", "value": "md value"}],
         "privateMetadata": [{"key": "pmd key", "value": "pmd value"}],
     }
@@ -484,6 +484,7 @@ def test_order_bulk_create(
     graphql_address_data,
     shipping_method_channel_PLN,
     variant,
+    voucher_without_channel,
 ):
     # given
     orders_count = Order.objects.count()
@@ -573,7 +574,8 @@ def test_order_bulk_create(
     assert db_order.display_gross_prices
     assert db_order.currency == "PLN"
     assert db_order.gift_cards.first().code == "never_expiry"
-    assert db_order.voucher.code == "mirumee"
+    assert db_order.voucher.code == voucher_without_channel.code
+    assert db_order.voucher_code == voucher_without_channel.code
     assert db_order.metadata["md key"] == "md value"
     assert db_order.private_metadata["pmd key"] == "pmd value"
     assert db_order.total_authorized_amount == Decimal("10")
diff --git a/saleor/order/calculations.py b/saleor/order/calculations.py
index b01b7bb4b41..f356e92a740 100644
--- a/saleor/order/calculations.py
+++ b/saleor/order/calculations.py
@@ -285,6 +285,7 @@ def _update_order_discount_for_voucher(order: Order):
                 reason=f"Voucher: {voucher.name}",  # type: ignore
                 voucher=voucher,
                 type=DiscountType.VOUCHER,
+                voucher_code=voucher.code,  # type: ignore
             )
 
     # Prefetch has to be cleared and refreshed to avoid returning cached discounts
diff --git a/saleor/order/migrations/0175_order_voucher_code.py b/saleor/order/migrations/0175_order_voucher_code.py
new file mode 100644
index 00000000000..32a2e16901f
--- /dev/null
+++ b/saleor/order/migrations/0175_order_voucher_code.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.2.21 on 2023-10-13 09:59
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("order", "0174_order_idx_order_created_at"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="order",
+            name="voucher_code",
+            field=models.CharField(blank=True, max_length=255, null=True),
+        ),
+    ]
diff --git a/saleor/order/migrations/0176_order_voucher_code_add_index.py b/saleor/order/migrations/0176_order_voucher_code_add_index.py
new file mode 100644
index 00000000000..975afc88224
--- /dev/null
+++ b/saleor/order/migrations/0176_order_voucher_code_add_index.py
@@ -0,0 +1,20 @@
+# Generated by Django 3.2.21 on 2023-10-04 10:26
+
+from django.db import migrations
+from django.contrib.postgres.operations import AddIndexConcurrently
+from django.contrib.postgres.indexes import GinIndex
+
+
+class Migration(migrations.Migration):
+    atomic = False
+
+    dependencies = [
+        ("order", "0175_order_voucher_code"),
+    ]
+
+    operations = [
+        AddIndexConcurrently(
+            model_name="order",
+            index=GinIndex(fields=["voucher_code"], name="order_voucher_code_idx"),
+        ),
+    ]
diff --git a/saleor/order/migrations/0177_set_order_voucher_code.py b/saleor/order/migrations/0177_set_order_voucher_code.py
new file mode 100644
index 00000000000..a5c96dd79a6
--- /dev/null
+++ b/saleor/order/migrations/0177_set_order_voucher_code.py
@@ -0,0 +1,26 @@
+from django.db import migrations
+from .tasks.saleor3_17 import set_order_voucher_code_task
+from django.db.models.signals import post_migrate
+from django.apps import apps as registry
+
+
+def set_order_voucher_code(apps, schema_editor):
+    def on_migrations_complete(sender=None, **kwargs):
+        set_order_voucher_code_task.delay()
+
+    sender = registry.get_app_config("discount")
+    post_migrate.connect(on_migrations_complete, weak=False, sender=sender)
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("discount", "0060_set_vouchercustomer_codes"),
+        ("order", "0176_order_voucher_code_add_index"),
+    ]
+
+    operations = [
+        migrations.RunPython(
+            set_order_voucher_code,
+            migrations.RunPython.noop,
+        ),
+    ]
diff --git a/saleor/order/migrations/tasks/__init_.py b/saleor/order/migrations/tasks/__init_.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/saleor/order/migrations/tasks/saleor3_17.py b/saleor/order/migrations/tasks/saleor3_17.py
new file mode 100644
index 00000000000..74da5b6a5f5
--- /dev/null
+++ b/saleor/order/migrations/tasks/saleor3_17.py
@@ -0,0 +1,51 @@
+from ....celeryconf import app
+from ...models import Order
+from ....discount.models import Voucher
+from django.db.models import Exists, OuterRef
+from django.db import transaction
+
+
+# For batch size 1000 with 2000 per model Order/Voucher/VoucherCode objects
+# Migration took 0.65 seconds.
+# Memory usage increased by 10.18 MiB.
+
+# For batch size 1000 with 1000 per model Order/Voucher/VoucherCode objects
+# (in the future one celery task)
+# Migration took 0.39 seconds.
+# Memory usage increased by 3.30 MiB.
+
+# For batch size 1000 with 100_000 per model Order/Voucher/VoucherCode objects
+# Migration took 46.44 seconds.
+# Memory usage increased by 294.54 MiB.
+BATCH_SIZE = 1000
+
+
+@app.task
+def set_order_voucher_code_task():
+    orders = Order.objects.filter(
+        voucher__isnull=False, voucher_code__isnull=True
+    ).order_by("pk")[:BATCH_SIZE]
+    if ids := list(orders.values_list("pk", flat=True)):
+        qs = Order.objects.filter(pk__in=ids)
+        with transaction.atomic():
+            _orders = list(qs.select_for_update(of=(["self"])))
+            set_voucher_code(qs)
+        set_order_voucher_code_task.delay()
+
+
+def set_voucher_code(orders):
+    vouchers_to_code = get_voucher_id_to_code_map(orders)
+    orders_list = []
+    for order in orders:
+        code = vouchers_to_code[order.voucher_id]
+        order.voucher_code = code
+        orders_list.append(order)
+    Order.objects.bulk_update(orders_list, ["voucher_code"])
+
+
+def get_voucher_id_to_code_map(orders):
+    vouchers = Voucher.objects.filter(Exists(orders.filter(voucher_id=OuterRef("pk"))))
+    voucher_id_to_code_map = {
+        voucher_id: code for voucher_id, code in vouchers.values_list("id", "code")
+    }
+    return voucher_id_to_code_map
diff --git a/saleor/order/models.py b/saleor/order/models.py
index c0f384d2bae..b09adcf1413 100644
--- a/saleor/order/models.py
+++ b/saleor/order/models.py
@@ -306,6 +306,9 @@ class Order(ModelWithMetadata, ModelWithExternalReference):
     voucher = models.ForeignKey(
         Voucher, blank=True, null=True, related_name="+", on_delete=models.SET_NULL
     )
+    voucher_code = models.CharField(
+        max_length=255, null=True, blank=True, db_index=False
+    )
     gift_cards = models.ManyToManyField(GiftCard, blank=True, related_name="orders")
     display_gross_prices = models.BooleanField(default=True)
     customer_note = models.TextField(blank=True, default="")
@@ -348,6 +351,7 @@ class Meta:
                 opclasses=["gin_trgm_ops"],
             ),
             models.Index(fields=["created_at"], name="idx_order_created_at"),
+            GinIndex(fields=["voucher_code"], name="order_voucher_code_idx"),
         ]
 
     def is_fully_paid(self):
diff --git a/saleor/order/tasks.py b/saleor/order/tasks.py
index 664901000be..8f81b044a22 100644
--- a/saleor/order/tasks.py
+++ b/saleor/order/tasks.py
@@ -9,7 +9,7 @@
 from ..channel.models import Channel
 from ..core.tracing import traced_atomic_transaction
 from ..core.utils.events import call_event
-from ..discount.models import Voucher, VoucherCustomer
+from ..discount.models import Voucher, VoucherCode, VoucherCustomer
 from ..payment.models import Payment, TransactionItem
 from ..plugins.manager import get_plugins_manager
 from ..warehouse.management import deallocate_stock_for_orders
@@ -53,12 +53,18 @@ def _bulk_release_voucher_usage(order_ids):
         count=Func(F("pk"), function="Count")
     ).values("count")
 
-    Voucher.objects.filter(
+    vouchers = Voucher.objects.filter(
         Exists(voucher_orders),
         usage_limit__isnull=False,
-    ).annotate(
-        order_count=Subquery(count_orders)
-    ).update(used=F("used") - F("order_count"))
+    )
+    vouchers.annotate(order_count=Subquery(count_orders)).update(
+        used=F("used") - F("order_count")
+    )
+
+    code_vouchers = vouchers.filter(id=OuterRef("voucher_id"))
+    VoucherCode.objects.filter(Exists(code_vouchers)).annotate(
+        voucher_used=Subquery(code_vouchers.values("used"))
+    ).update(used=F("voucher_used"))
 
     voucher_customer_orders = Order.objects.filter(
         voucher=OuterRef("voucher__id"),
diff --git a/saleor/order/tests/test_tasks.py b/saleor/order/tests/test_tasks.py
index 92ff5d0c8b7..bed59625e2f 100644
--- a/saleor/order/tests/test_tasks.py
+++ b/saleor/order/tests/test_tasks.py
@@ -84,9 +84,16 @@ def test_expire_orders_task_check_multiple_vouchers(
     voucher.used = 2
     voucher.usage_limit = 2
     voucher.save()
+    voucher_code = voucher.codes.first()
+    voucher_code.used = 2
+    voucher_code.save(update_fields=["used"])
+
     voucher_percentage.used = 1
     voucher_percentage.usage_limit = 1
     voucher_percentage.save()
+    voucher_percentage_code = voucher_percentage.codes.first()
+    voucher_percentage_code.used = 1
+    voucher_percentage_code.save(update_fields=["used"])
 
     order_1 = order_list[0]
     order_1.status = OrderStatus.UNCONFIRMED
@@ -127,9 +134,14 @@ def test_expire_orders_task_check_multiple_vouchers(
     ).exists()
 
     voucher.refresh_from_db()
+    voucher_code.refresh_from_db()
     assert voucher.used == 1
+    assert voucher_code.used == 1
+
     voucher_percentage.refresh_from_db()
+    voucher_percentage_code.refresh_from_db()
     assert voucher_percentage.used == 0
+    assert voucher_percentage_code.used == 0
 
 
 def test_expire_orders_task_creates_order_events(order_list, allocations, channel_USD):
diff --git a/saleor/tests/fixtures.py b/saleor/tests/fixtures.py
index 283e98578e2..66a4cb611f4 100644
--- a/saleor/tests/fixtures.py
+++ b/saleor/tests/fixtures.py
@@ -68,6 +68,7 @@
     PromotionTranslation,
     Voucher,
     VoucherChannelListing,
+    VoucherCode,
     VoucherCustomer,
     VoucherTranslation,
 )
@@ -3888,7 +3889,9 @@ def product_with_images(
 
 @pytest.fixture
 def voucher_without_channel(db):
-    return Voucher.objects.create(code="mirumee")
+    voucher = Voucher.objects.create(code="saleor")
+    VoucherCode.objects.create(code=voucher.code, voucher=voucher)
+    return voucher
 
 
 @pytest.fixture
@@ -3914,9 +3917,10 @@ def voucher_with_many_channels(voucher, channel_PLN):
 @pytest.fixture
 def voucher_percentage(channel_USD):
     voucher = Voucher.objects.create(
-        code="saleor",
+        code="saleor-percentage",
         discount_value_type=DiscountValueType.PERCENTAGE,
     )
+    VoucherCode.objects.create(code=voucher.code, voucher=voucher, used=voucher.used)
     VoucherChannelListing.objects.create(
         voucher=voucher,
         channel=channel_USD,
@@ -3936,7 +3940,7 @@ def voucher_specific_product_type(voucher_percentage, product):
 
 @pytest.fixture
 def voucher_with_high_min_spent_amount(channel_USD):
-    voucher = Voucher.objects.create(code="mirumee")
+    voucher = Voucher.objects.create(code="saleor")
     VoucherChannelListing.objects.create(
         voucher=voucher,
         channel=channel_USD,
@@ -3949,13 +3953,14 @@ def voucher_with_high_min_spent_amount(channel_USD):
 @pytest.fixture
 def voucher_shipping_type(channel_USD):
     voucher = Voucher.objects.create(
-        code="mirumee", type=VoucherType.SHIPPING, countries="IS"
+        code="shipping-voucher", type=VoucherType.SHIPPING, countries="IS"
     )
     VoucherChannelListing.objects.create(
         voucher=voucher,
         channel=channel_USD,
         discount=Money(10, channel_USD.currency_code),
     )
+    VoucherCode.objects.create(code=voucher.code, voucher=voucher)
     return voucher
 
 


Code review comments:
diff_hunk: @@ -120,12 +121,17 @@ def _process_voucher_data_for_order(checkout_info: "CheckoutInfo") -> dict:
         return {}
 
     if voucher.usage_limit:
-        increase_voucher_usage(voucher)
+        # type-ignore added as we ensure that voucher code instance is created
path: saleor/checkout/complete_checkout.py
text: @user1:
Instead of type ignore, which will hide you all arg-type problems. Can't you make:
```python
    code = cast(VoucherCode, code)
    if voucher.usage_limit:
        increase_voucher_usage(voucher, code)
    if voucher.apply_once_per_customer:
        customer_email = cast(str, checkout_info.get_customer_email())
        add_voucher_usage_by_customer(
            voucher, code, customer_email
        )
```