Skip to content

Instantly share code, notes, and snippets.

@leepa
Last active March 16, 2026 20:56
Show Gist options
  • Select an option

  • Save leepa/5fe507217d6c1b2673a5faac5eaaa431 to your computer and use it in GitHub Desktop.

Select an option

Save leepa/5fe507217d6c1b2673a5faac5eaaa431 to your computer and use it in GitHub Desktop.
Key Per Tier Fulfilment — Per-Tier Fulfilment Type (revised plan)

Key Per Tier Fulfilment — Implementation Plan

Context

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.

Design Decisions

  • Scope: fulfilment_type lives on BundleTierConfig (per-tier)
  • Cumulative: cumulative_fulfilment boolean on BundleCommerceConfig (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_label and optional display_image for 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) and TierKey (from KEY_PER_TIER tiers). The claim page renders both sections.

Why per-tier works naturally

  1. ProductKeyGroupMappingConfig is already per-tier-per-product
  2. FulfilmentPanelConfig already iterates per-tier
  3. _resolve_selected_tiers processes tiers individually
  4. Stock checking is already per-tier
  5. The OrderKey union (GameKey | CouponKey | TierKey) handles mixed types in one order
  6. Orphan cleanup is simpler — only clean the changed tier, not all tiers

Effort Estimates

Engineer Estimate (~8-9 working days)

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

Manual QA Effort (~2-3 days)

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_type appears 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

Reporting Impact

No change

  • 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.

New proto field (Phase 2.4)

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.

MongoDB-only reporting

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.


Phase 1: Constants & Models

1.1 Add FulfilmentType enum

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"

1.2 Add fulfilment_type to BundleTierConfig

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.

1.3 Add cumulative_fulfilment to BundleCommerceConfig

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).

1.4 Create TierKeyGroupMappingConfig model

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 on country_codes
  • Reuse existing clean_country_codes validator from fenix/store/models/helpers/validators.py
  • Reuse existing KeyGroupNameValidator from fenix/store/validators.py
  • Add get_country_codes_str() method (same pattern as ProductKeyGroupMappingConfig)
  • display_label is required — customers need to know what each key is for
  • display_image uses the same Image model as other models (e.g. OrderClaimPage.banner_image), SET_NULL so 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".

1.5 Orphan cleanup on tier fulfilment_type change

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_mappings for this tier
  • If changed to KEY_PER_TIER: delete product_key_mappings for 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 ProductKeyGroupMappingConfig for 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()

1.6 Migration

Create migration adding:

  • fulfilment_type CharField to BundleTierConfig (default "key_per_product")
  • cumulative_fulfilment BooleanField to BundleCommerceConfig (default True)
  • TierKeyGroupMappingConfig table with constraints and indexes

Safe additive migration — no existing data changes.


Phase 2: DTOs

2.1 Add TierKey class

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.

2.2 Update OrderKey union

File: fenix/orders/services/order/dto.py (~line 218)

OrderKey = GameKey | CouponKey | TierKey

2.3 Backward compatibility

The 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.

2.4 Add fulfilment_type to protobuf schema

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.


Phase 3: QuerySet & Adapter Updates

3.1 Add queryset methods to BundleTierConfigQuerySet

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".


Phase 4: Order Service

4.1 Update TiersData

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: bool

4.2 Update _resolve_selected_tiers

File: 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,
)

4.3 Add _resolve_tier_key_mappings method

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 TierKeyGroupMappingConfig entries matching the customer's country (a tier can have multiple items/key groups — e.g., 3 software licenses per tier)
  • Raise OrderCreationError if 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.

4.4 Update _resolve_key_mappings

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.

4.5 Add _get_tier_keys_from_keystone method

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_mappings dict 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

4.6 Update create_order method — the key change

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.

4.7 Update out-of-stock handling

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 models

Update _send_out_of_stock_email to handle both product-keyed and tier-keyed OOS items.


Phase 5: Admin UI

5.1 Update FulfilmentPanelConfig

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...

5.2 Update panel template

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)

5.3 Create tier-level Cotton components

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 as product/index.html.
  • form.html: HTMX form with tier_id, key_group, display_label, display_image, country_codes, is_out_of_stock. Same pattern as product/form.html but without product_id.

5.4 Create tier mapping admin views

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 mappings
  • CreateTierMappingConfigView — GET: show form, POST: create mapping
  • UpdateTierMappingConfigView — GET: show form with existing data, POST: update
  • DeleteTierMappingConfigView — DELETE: remove mapping

5.5 Create form

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.

5.6 Register URLs

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/

Phase 6: Frontend Display (Order Claim Page)

6.1 Add _get_tier_keys method

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"
    ]

6.2 Update get_root_view_context

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).

6.3 Update reveal_keys view

File: fenix/store/views/reveal_keys.py (~line 73)

Add tier_keys = OrderClaimPage._get_tier_keys(order_obj) and add to context.

6.4 Create tier key template

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.

6.5 Update keys_list.html

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 %}

6.6 Update keys_section.html

File: fenix/store/templates/cotton/store/order_claim/keys_section.html

Pass tier_keys to the keys_list component.


Phase 7: Stock Checking

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).


Files Summary

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

Verification

Unit Tests

# 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

New Tests to Write

  1. DTO tests: TierKey construction, OrderKey union deserialization (all 3 types), backward compat with existing MongoDB docs
  2. Model tests: TierKeyGroupMappingConfig CRUD, validation, constraints, tier-level fulfilment_type validation (BOOK rejection)
  3. Service tests: _resolve_tier_key_mappings (cumulative/non-cumulative, missing mappings), _get_tier_keys_from_keystone (happy path, OOS), create_order with mixed types (some tiers KEY_PER_PRODUCT, some KEY_PER_TIER), create_order with all-KEY_PER_TIER, create_order with all-KEY_PER_PRODUCT (backward compat)
  4. Admin tests: Tier mapping CRUD views, form validation
  5. Display tests: _get_tier_keys with/without tier keys, mixed claim page with both GameKey and TierKey
  6. Out-of-stock tests: OOS for KEY_PER_TIER key groups, OOS for mixed bundles

Manual Verification

  1. Create a BundleCommerceConfig with mixed tiers (some KEY_PER_PRODUCT, some KEY_PER_TIER)
  2. Add tier key mappings to KEY_PER_TIER tiers, product key mappings to KEY_PER_PRODUCT tiers
  3. Place test order: uv run python manage.py dev_place_order
  4. Verify order in MongoDB has both GameKey and TierKey objects
  5. Verify order claim page displays both types correctly

Linting

uv run ruff format .
uv run ruff check --fix .
uv run djlint --reformat .
uv run mypy .
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment