The current fulfilment system only supports "key per product" — each product in a tier maps to a Keystone key group, and one key is extracted per product at order time. Software bundles need a "key per tier" mode where a single activation key represents the entire tier (all products in it), rather than individual product keys.
Design decision: fulfilment_type lives on BundleTierConfig (per-tier, not per-bundle) so that individual tiers within the same bundle can use different fulfilment strategies. For example, a bundle could have Bronze (KEY_PER_PRODUCT: 3 games, 3 individual keys) and Gold (KEY_PER_TIER: 1 license key covering all Gold content). A single order can contain both GameKey and TierKey objects.
- Scope:
fulfilment_typelives onBundleTierConfig(per-tier) - Cumulative:
cumulative_fulfilmentboolean onBundleCommerceConfig(bundle level) controls whether customer gets keys for all tiers up to purchased (True) or just the purchased tier (False). This is a bundle-level concept — it controls whether buying Gold includes Bronze+Silver content, regardless of how each tier distributes its keys. - Bundle types: KEY_PER_TIER allowed for GAME and SOFTWARE only (not BOOK) — add validation on
BundleTierConfig.clean() - Display: Each tier key mapping has a required
display_labeland optionaldisplay_imagefor claim page UX. Avoids the problem of customers receiving multiple keys with no way to tell them apart. - Mixed orders: A single order can contain both
GameKey(from KEY_PER_PRODUCT tiers) andTierKey(from KEY_PER_TIER tiers). The claim page renders both sections.
ProductKeyGroupMappingConfigis already per-tier-per-productFulfilmentPanelConfigalready iterates per-tier_resolve_selected_tiersprocesses tiers individually- Stock checking is already per-tier
- The
OrderKeyunion (GameKey | CouponKey | TierKey) handles mixed types in one order - Orphan cleanup is simpler — only clean the changed tier, not all tiers
| Area | Days | Notes |
|---|---|---|
| Models + migration | 0.5 | Straightforward, follows existing patterns |
| DTOs + protobuf | 0.5 | TierKey class, union update, proto field |
| Service layer | 1.5 | Highest complexity — mixed-type key resolution in create_order, new resolution/extraction, OOS |
| Admin UI (views, forms, templates) | 1.5 | Pattern-following but substantial — 4 CBVs, form, 2 Cotton templates, URLs |
| Frontend display (order claim) | 0.5 | New template + context plumbing |
| Stock checking | 0.5 | Queryset annotations + branching in helpers |
| Tests | 2-3 | DTO backward compat, service (mixed types, cumulative/non-cumulative/OOS), admin CRUD, display |
| Code review + iteration | 1 |
New feature testing (~1-2 days):
- Configure a tier as KEY_PER_TIER in admin, verify fulfilment panel switches correctly for that tier
- Configure tier key mappings (full CRUD: create, edit, delete, validation errors)
- Place order with a KEY_PER_TIER tier + cumulative=True (3 tiers → mixed keys from all tiers)
- Place order with a KEY_PER_TIER tier + cumulative=False (→ only purchased tier's keys)
- Place order with MIXED tiers (some KEY_PER_PRODUCT, some KEY_PER_TIER) → both GameKey and TierKey in order
- Verify order claim page displays both game keys and tier keys correctly
- Verify reveal keys flow works for tier keys (HTMX reveal)
- Test out-of-stock marking (Keystone OOS → tier mapping marked, email sent)
- Test stock annotations on bundle display page (in stock / OOS badge)
- Test BOOK bundle type rejection when selecting KEY_PER_TIER on a tier
- Verify
fulfilment_typeappears correctly in BigQuery (proto field)
Regression testing (~1-2 days):
- Existing KEY_PER_PRODUCT bundle: full order flow unchanged (checkout → payment → keys → claim)
- Existing orders in MongoDB: still display correctly on claim page (backward compat with GameKey)
- Admin fulfilment panel for KEY_PER_PRODUCT tiers: unchanged behavior
- Finance calculations: unaffected (spot check with
dev_place_order) - Stock checking for KEY_PER_PRODUCT tiers: unchanged annotations
- Out-of-stock flow for KEY_PER_PRODUCT: unchanged (Keystone OOS → ProductKeyGroupMappingConfig marked)
- Protobuf/BigQuery: existing orders still serialize correctly, new field is optional
- Protobuf/BigQuery: Products, finance, tiers, customer, payment data all unchanged. Keys are NOT in the proto (never were).
- Finance calculations: Identical — products still exist in tiers for publisher splits regardless of fulfilment type.
- Impact.com affiliate tracking: Order-level only, doesn't reference keys.
Add fulfilment_type to the proto schema so the data warehouse can distinguish order types. With per-tier fulfilment, this captures the selected tier's fulfilment type.
Any direct MongoDB queries filtering on keys.product_id will miss KEY_PER_TIER orders (which have tier_key/tier_name instead). Flag to any teams doing ad-hoc Mongo queries.
File: fenix/store/constants.py
class FulfilmentType(models.TextChoices):
KEY_PER_PRODUCT = "key_per_product", "Key Per Product"
KEY_PER_TIER = "key_per_tier", "Key Per Tier"File: fenix/store/models/bundle_config.py (on BundleTierConfig, ~line 514)
fulfilment_type = models.CharField(
max_length=20,
choices=FulfilmentType.choices,
default=FulfilmentType.KEY_PER_PRODUCT,
help_text="How keys are distributed for this tier: one key per product, or one key representing the whole tier.",
)Add FieldPanel("fulfilment_type") to BundleTierConfig.panels (~line 551).
Add clean() validation: if fulfilment_type == KEY_PER_TIER, check that the parent config's BundlePage bundle_type is not BOOK. Note: access bundle type via self.config → BundlePage relationship.
File: fenix/store/models/bundle_config.py (~line 169, after tax_category)
cumulative_fulfilment = models.BooleanField(
default=True,
help_text="If True, customer receives keys for all tiers up to purchased tier. "
"If False, only the purchased tier's keys are extracted.",
)Add FieldPanel("cumulative_fulfilment") to content_panels (~line 185).
File: fenix/store/models/bundle_config.py (after ProductKeyGroupMappingConfig, ~line 845)
class TierKeyGroupMappingConfig(Orderable):
"""Tier-level fulfilment key group mapping. Used when fulfilment_type is KEY_PER_TIER."""
tier = ParentalKey(
"store.BundleTierConfig",
on_delete=models.CASCADE,
related_name="tier_key_mappings",
)
key_group = models.CharField(max_length=255, validators=[KeyGroupNameValidator])
country_codes = models.JSONField(default=list)
is_out_of_stock = models.BooleanField(default=False)
# Display fields for order claim page — stored in order at creation time
display_label = models.CharField(
max_length=255,
help_text="Label shown to customers on the key claim page (e.g., 'Adobe Photoshop License')",
)
display_image = models.ForeignKey(
"core.GoogleCloudImage",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="+",
help_text="Optional image shown next to the key on the claim page",
)- Unique constraint:
["tier", "key_group"](one key group per tier mapping entry) - Indexes:
["tier"],["is_out_of_stock", "tier"], GinIndex oncountry_codes - Reuse existing
clean_country_codesvalidator fromfenix/store/models/helpers/validators.py - Reuse existing
KeyGroupNameValidatorfromfenix/store/validators.py - Add
get_country_codes_str()method (same pattern asProductKeyGroupMappingConfig) display_labelis required — customers need to know what each key is fordisplay_imageuses the same Image model as other models (e.g.OrderClaimPage.banner_image),SET_NULLso deleting an image doesn't break the mapping
Add TYPE_CHECKING annotation on BundleTierConfig (~line 676):
tier_key_mappings: QuerySet["TierKeyGroupMappingConfig"]Update __all__ to include "TierKeyGroupMappingConfig".
File: fenix/store/models/bundle_config.py
Update BundleTierConfig.save() to clean up stale mappings for that tier only when fulfilment_type changes:
- If changed to KEY_PER_PRODUCT: delete
tier_key_mappingsfor this tier - If changed to KEY_PER_TIER: delete
product_key_mappingsfor this tier
Use transaction.on_commit to avoid deleting during an incomplete save.
Also update BundleCommerceConfig.cleanup_stale_key_mappings() (called via transaction.on_commit alongside existing cleanup_stale_publisher_shares()) to handle:
- Product removed from tier → delete
ProductKeyGroupMappingConfigfor products no longer in that tier
def cleanup_stale_key_mappings(self):
for tier in self.tiers.all():
# Product key mappings for products no longer in tier
current_product_ids = set(tier.tier_products.values_list("product_id", flat=True))
tier.product_key_mappings.exclude(product_id__in=current_product_ids).delete()Create migration adding:
fulfilment_typeCharField toBundleTierConfig(default"key_per_product")cumulative_fulfilmentBooleanField toBundleCommerceConfig(defaultTrue)TierKeyGroupMappingConfigtable with constraints and indexes
Safe additive migration — no existing data changes.
File: fenix/orders/services/order/dto.py (after GameKey, ~line 215)
class TierKey(OrderKeyBase):
kind: Literal["TIER"] = Field("TIER", exclude=True)
tier_key: str = Field(min_length=1, description="Tier slug identifier")
tier_name: str = Field(min_length=1, description="Tier display name")
display_label: str = Field(min_length=1, description="Customer-facing label (e.g., 'Adobe Photoshop License')")
display_image_url: str | None = Field(default=None, description="Optional image URL for claim page display")display_label and display_image_url are snapshotted from TierKeyGroupMappingConfig at order creation time, so they're stable even if the admin changes them later.
File: fenix/orders/services/order/dto.py (~line 218)
OrderKey = GameKey | CouponKey | TierKeyThe kind field has exclude=True so it's NOT persisted to MongoDB. Pydantic resolves the union by field shape. TierKey has unique fields (tier_key, tier_name) that neither GameKey nor CouponKey have, so deserialization of existing orders works correctly.
File: fenix/orders/schemas/order.proto (~line 205, after bundle_type)
optional string fulfilment_type = 21; // "key_per_product" or "key_per_tier"Regenerate protobuf Python code after editing the .proto file.
File: fenix/orders/services/order/dto.py — Add field to Order model (~line 344, after bundle_type)
fulfilment_type: str = Field(
default="key_per_product",
description="Fulfilment type of the selected tier: key_per_product or key_per_tier",
)Update Order.to_protobuf() and Order.to_protobuf_refund() to include fulfilment_type.
This field captures the selected tier's fulfilment type for reporting. Populated from selected_tier.fulfilment_type during create_order.
File: fenix/store/models/bundle_config.py
Add with_tier_key_mappings() prefetch method (parallel to with_key_mappings(), ~line 331):
def with_tier_key_mappings(self):
return self.prefetch_related("tier_key_mappings")Update with_stock_annotations() (~line 349) to also annotate tier-level stock:
tier_key_mappings_in_stock_count=Count(
"tier_key_mappings",
filter=Q(tier_key_mappings__is_out_of_stock=False,
tier_key_mappings__country_codes__contains=[country_code]),
),
tier_key_mappings_for_country_count=Count(
"tier_key_mappings",
filter=Q(tier_key_mappings__country_codes__contains=[country_code]),
),Update for_order() (~line 441) to chain .with_tier_key_mappings() and add "tier_key_mappings" to prefetch.
Update for_fulfillment() (~line 459) to also prefetch "tier_key_mappings".
File: fenix/orders/services/order/service.py (~line 111)
Add fields:
class TiersData(NamedTuple):
"""Selected tier data for commerce config tier system."""
tiers: list[BundleTierConfig]
products: list[TierProductConfig]
mappings: list[ProductKeyGroupMappingConfig]
tier_key_mappings: list[TierKeyGroupMappingConfig]
cumulative_fulfilment: boolFile: fenix/orders/services/order/service.py (~line 274)
Also collect tier_key_mappings from each selected tier's prefetched tier.tier_key_mappings.all(). Pass cumulative_fulfilment from the bundle's commerce config.
selected_tiers_tier_key_mappings: list[TierKeyGroupMappingConfig] = []
for tier in selected_tiers:
selected_tiers_mappings.extend(list(tier.product_key_mappings.all()))
selected_tiers_products.extend(list(tier.tier_products.all()))
selected_tiers_tier_key_mappings.extend(list(tier.tier_key_mappings.all()))
commerce_config = bundle.source_page.commerce_config
cumulative = commerce_config.cumulative_fulfilment if commerce_config else True
return TiersData(
tiers=selected_tiers,
products=selected_tiers_products,
mappings=selected_tiers_mappings,
tier_key_mappings=selected_tiers_tier_key_mappings,
cumulative_fulfilment=cumulative,
)File: fenix/orders/services/order/service.py (after _resolve_key_mappings, ~line 499)
New method parallel to _resolve_key_mappings:
- Takes
selected_tiers_data,bundle_id,country_code - Filters to only KEY_PER_TIER tiers
- If
cumulative_fulfilment=False, filter to only the purchased tier (last in list) - For each relevant tier, find ALL
TierKeyGroupMappingConfigentries matching the customer's country (a tier can have multiple items/key groups — e.g., 3 software licenses per tier) - Raise
OrderCreationErrorif any relevant tier has ZERO mappings for the customer's country - Returns
list[tuple[BundleTierConfig, TierKeyGroupMappingConfig]](tier + mapping pairs, multiple pairs per tier possible)
Country code overlap between different key groups within the same tier is expected and correct — a US customer should receive keys from ALL key groups configured for their country in that tier.
File: fenix/orders/services/order/service.py (~line 437)
Filter to only process products from tiers where fulfilment_type == KEY_PER_PRODUCT. Products from KEY_PER_TIER tiers are skipped here — they still exist for finance calculations but don't get individual product keys.
Build a set of KEY_PER_PRODUCT tier IDs, then filter selected_tiers_data.products and selected_tiers_data.mappings to only include items from those tiers.
File: fenix/orders/services/order/service.py (after _get_order_keys_from_keystone, ~line 272)
Similar to _get_order_keys_from_keystone but creates TierKey objects:
- Builds
key_groups_to_tier_mappingsdict mapping key_group name to(tier, mapping)tuple - A tier can have multiple key groups (e.g., 3 software licenses), so multiple entries may share the same tier
- Sends all key groups to
keystone_api_service.extract_keys() - For results, creates
TierKey(tier_key=tier.key, tier_name=tier.name, display_label=mapping.display_label, display_image_url=mapping.display_image.url if mapping.display_image else None, ...) - Returns
list[TierKey] | KeystoneOutOfStockError | KeystoneApiError
File: fenix/orders/services/order/service.py (~line 620)
Instead of a single key extraction path, process BOTH fulfilment types:
selected_tiers_data = self._resolve_selected_tiers(bundle, request.tier_selection)
product_tiers = [t for t in selected_tiers_data.tiers if t.fulfilment_type == FulfilmentType.KEY_PER_PRODUCT]
tier_key_tiers = [t for t in selected_tiers_data.tiers if t.fulfilment_type == FulfilmentType.KEY_PER_TIER]
coupons = bundle.get_coupons()
metadata = { ... }
all_keys: list[OrderKey] = []
# 1. Extract product-level keys from KEY_PER_PRODUCT tiers
if product_tiers:
key_mappings = self._resolve_key_mappings(selected_tiers_data, ...)
product_keys = self._get_order_keys_from_keystone(key_mappings, coupons, metadata)
match product_keys:
case KeystoneOutOfStockError(): ...
case KeystoneApiError(): ...
all_keys.extend(product_keys)
# 2. Extract tier-level keys from KEY_PER_TIER tiers
if tier_key_tiers:
tier_mappings = self._resolve_tier_key_mappings(selected_tiers_data, ...)
# Only pass coupons if not already extracted above
tier_keys = self._get_tier_keys_from_keystone(
tier_mappings, coupons if not product_tiers else [], metadata
)
match tier_keys:
case KeystoneOutOfStockError(): ...
case KeystoneApiError(): ...
all_keys.extend(tier_keys)Everything else (products, finance, order construction) remains unchanged. Products still exist for finance purposes regardless of fulfilment type.
File: fenix/orders/services/order/service.py (~line 536)
_handle_out_of_stock_commerce_config must check BOTH ProductKeyGroupMappingConfig and TierKeyGroupMappingConfig for matching key groups. A key group name from Keystone could come from either model:
def _handle_out_of_stock_commerce_config(self, key_groups, bundle, order_id):
commerce_config = bundle.source_page.commerce_config
# Mark product-level key groups as out of stock
product_updated = ProductKeyGroupMappingConfig.objects.filter(
key_group__in=key_groups, is_out_of_stock=False, tier__config=commerce_config,
).update(is_out_of_stock=True)
# Mark tier-level key groups as out of stock
tier_updated = TierKeyGroupMappingConfig.objects.filter(
key_group__in=key_groups, is_out_of_stock=False, tier__config=commerce_config,
).update(is_out_of_stock=True)
if product_updated == 0 and tier_updated == 0:
logger.info("All key groups already out of stock", key_groups=key_groups)
return
# ... send email with context from both modelsUpdate _send_out_of_stock_email to handle both product-keyed and tier-keyed OOS items.
File: fenix/store/panels.py (~line 10)
Update get_context_data to pass fulfilment_type per tier to template context. If KEY_PER_TIER, collect tier_key_mappings instead of product-level mappings for that tier:
for tier in tiers:
if tier.fulfilment_type == FulfilmentType.KEY_PER_TIER:
tier_items.append({
"tier": tier,
"fulfilment_type": tier.fulfilment_type,
"tier_is_draft": not tier.id,
"tier_key_mappings": list(tier.tier_key_mappings.all()),
"config_id": self.instance.uuid,
})
else:
for tier_product in tier.tier_products.all():
# existing product-level logic...File: fenix/store/templates/admin/fulfilment_panel_config.html
Per-tier conditional rendering:
- KEY_PER_PRODUCT: show existing product-level mapping table (unchanged)
- KEY_PER_TIER: show tier-level mapping table (new component)
Create new templates in fenix/store/templates/cotton/admin/fulfilment_config/tier/:
index.html: Table showing tier's key group mappings (key group, display label, country codes, stock status, edit/delete buttons). Same HTMX pattern asproduct/index.html.form.html: HTMX form withtier_id,key_group,display_label,display_image,country_codes,is_out_of_stock. Same pattern asproduct/form.htmlbut withoutproduct_id.
File: fenix/store/views/admin/fulfilment_tier_config.py (new file)
Four CBVs following the exact HTMX pattern from fulfilment_config.py:
TierMappingConfigDetailView— GET: display tier's key group mappingsCreateTierMappingConfigView— GET: show form, POST: create mappingUpdateTierMappingConfigView— GET: show form with existing data, POST: updateDeleteTierMappingConfigView— DELETE: remove mapping
File: fenix/store/forms/admin.py
Add TierKeyGroupMappingConfigForm — same as ProductKeyGroupMappingConfigForm (~line 23) but without product_id field. Fields: tier_id, mapping_id, key_group, display_label, display_image, country_codes, is_out_of_stock. Validation: duplicate key group check per tier, country code overlap check per tier.
File: fenix/store/wagtail_hooks.py (~line 94, inside register_fulfilment_config_urls)
Add 4 URL patterns for tier mapping CRUD:
commerce/<uuid:config_id>/fulfilment/tier/<int:tier_id>/tier-mapping/
commerce/<uuid:config_id>/fulfilment/tier/<int:tier_id>/tier-mapping/add/
commerce/<uuid:config_id>/fulfilment/tier-mapping/<int:pk>/
commerce/<uuid:config_id>/fulfilment/tier-mapping/<int:pk>/delete/
File: fenix/store/models/order_claim_page.py
class TierKeyDisplayItem(NamedTuple):
key: "OrderKey"
display_label: str
display_image_url: str | None
@classmethod
def _get_tier_keys(cls, order: "Order") -> list[TierKeyDisplayItem]:
return [
TierKeyDisplayItem(
key=key,
display_label=key.display_label,
display_image_url=key.display_image_url,
)
for key in order.keys if key.kind == "TIER"
]File: fenix/store/models/order_claim_page.py (~line 379)
After computing games and coupon_list, also compute:
tier_keys = self._get_tier_keys(order_obj)Add "tier_keys": tier_keys to the order data dict (~line 395).
File: fenix/store/views/reveal_keys.py (~line 73)
Add tier_keys = OrderClaimPage._get_tier_keys(order_obj) and add to context.
File: fenix/store/templates/cotton/store/order_claim/tier_key_item.html (new)
Similar structure to key_item.html but uses display_label instead of product title, and display_image_url (if present) instead of product thumbnail. Falls back to label-only when no image. Shows key value with copy button, key_notice if present.
File: fenix/store/templates/cotton/store/order_claim/keys_list.html
Add tier keys section between games and coupons:
{% if tier_keys %}
<div class="row pb-1">
<h2>{{ order_claim_common.keys_heading_text }}</h2>
</div>
<div class="row">
<div class="col-12 item-list-wrapper mb-15">
{% for item in tier_keys %}
<c-store.order_claim.tier_key_item :key="item.key" :display_label="item.display_label" :display_image_url="item.display_image_url" />
{% endfor %}
</div>
</div>
{% endif %}File: fenix/store/templates/cotton/store/order_claim/keys_section.html
Pass tier_keys to the keys_list component.
File: fenix/store/models/bundle/helpers.py (or wherever stock status is computed)
Per-tier stock check based on each tier's fulfilment_type:
if tier.fulfilment_type == FulfilmentType.KEY_PER_TIER:
total_for_country = getattr(tier, "tier_key_mappings_for_country_count", 0)
in_stock_count = getattr(tier, "tier_key_mappings_in_stock_count", 0)
has_mappings = total_for_country > 0
is_in_stock = has_mappings and in_stock_count == total_for_country # ALL must be in stock
else:
# Existing KEY_PER_PRODUCT logic unchanged
...For KEY_PER_TIER, a tier is in stock only if ALL its key groups for the country are in stock (since a tier can have multiple items).
| File | Change |
|---|---|
fenix/store/constants.py |
Add FulfilmentType enum |
fenix/store/models/bundle_config.py |
Add fulfilment_type to BundleTierConfig, cumulative_fulfilment to BundleCommerceConfig, new TierKeyGroupMappingConfig model, update querysets, cleanup logic |
fenix/orders/services/order/dto.py |
Add TierKey, update OrderKey union, add fulfilment_type field to Order, update to_protobuf() |
fenix/orders/schemas/order.proto |
Add fulfilment_type field (field 21) to OrderMessage |
fenix/orders/schemas/order_pb2.py |
Regenerated from proto |
fenix/orders/schemas/order_pb2.pyi |
Regenerated from proto |
fenix/orders/services/order/service.py |
Update TiersData, add _resolve_tier_key_mappings, _get_tier_keys_from_keystone, update create_order for mixed types, update _resolve_key_mappings to filter by fulfilment type, update out-of-stock handling |
fenix/store/panels.py |
Update FulfilmentPanelConfig context for per-tier fulfilment type |
fenix/store/templates/admin/fulfilment_panel_config.html |
Per-tier conditional rendering |
fenix/store/templates/cotton/admin/fulfilment_config/tier/index.html |
New: tier mapping table |
fenix/store/templates/cotton/admin/fulfilment_config/tier/form.html |
New: tier mapping form |
fenix/store/views/admin/fulfilment_tier_config.py |
New: CRUD views for tier mappings |
fenix/store/forms/admin.py |
Add TierKeyGroupMappingConfigForm |
fenix/store/wagtail_hooks.py |
Register tier mapping URLs |
fenix/store/models/order_claim_page.py |
Add _get_tier_keys, update context |
fenix/store/views/reveal_keys.py |
Add tier keys to reveal context |
fenix/store/templates/cotton/store/order_claim/tier_key_item.html |
New: tier key display component |
fenix/store/templates/cotton/store/order_claim/keys_list.html |
Add tier keys section |
fenix/store/templates/cotton/store/order_claim/keys_section.html |
Pass tier_keys through |
fenix/store/models/bundle/helpers.py |
Per-tier stock check branching |
# Existing tests must pass (no finance/order changes for KEY_PER_PRODUCT)
uv run pytest fenix/orders/services/finance/tests/ -v
uv run pytest fenix/orders/services/order/tests/ -v
uv run pytest fenix/store/tests/views/admin/test_fulfilment_config.py -v- DTO tests:
TierKeyconstruction,OrderKeyunion deserialization (all 3 types), backward compat with existing MongoDB docs - Model tests:
TierKeyGroupMappingConfigCRUD, validation, constraints, tier-levelfulfilment_typevalidation (BOOK rejection) - Service tests:
_resolve_tier_key_mappings(cumulative/non-cumulative, missing mappings),_get_tier_keys_from_keystone(happy path, OOS),create_orderwith mixed types (some tiers KEY_PER_PRODUCT, some KEY_PER_TIER),create_orderwith all-KEY_PER_TIER,create_orderwith all-KEY_PER_PRODUCT (backward compat) - Admin tests: Tier mapping CRUD views, form validation
- Display tests:
_get_tier_keyswith/without tier keys, mixed claim page with bothGameKeyandTierKey - Out-of-stock tests: OOS for KEY_PER_TIER key groups, OOS for mixed bundles
- Create a
BundleCommerceConfigwith mixed tiers (some KEY_PER_PRODUCT, some KEY_PER_TIER) - Add tier key mappings to KEY_PER_TIER tiers, product key mappings to KEY_PER_PRODUCT tiers
- Place test order:
uv run python manage.py dev_place_order - Verify order in MongoDB has both
GameKeyandTierKeyobjects - Verify order claim page displays both types correctly
uv run ruff format .
uv run ruff check --fix .
uv run djlint --reformat .
uv run mypy .