Skip to content

Instantly share code, notes, and snippets.

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

  • Save leepa/0a35abffd295107f0809e9458ed44772 to your computer and use it in GitHub Desktop.

Select an option

Save leepa/0a35abffd295107f0809e9458ed44772 to your computer and use it in GitHub Desktop.
Key Per Tier Fulfilment - Implementation Plan

Key Per Tier Fulfilment

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. This is a substantial schema departure requiring changes across models, DTOs, services, admin UI, and frontend display.

Design Decisions (confirmed with user)

  • Scope: fulfilment_type lives on BundleCommerceConfig (bundle level, not per-tier)
  • Cumulative: Config boolean cumulative_fulfilment controls whether customer gets keys for all tiers up to purchased (True) or just the purchased tier (False)
  • Bundle types: GAME and SOFTWARE only (not BOOK) - add validation
  • Display: Each tier key 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.

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 - branching 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 (cumulative/non-cumulative/OOS), admin CRUD, display
Code review + iteration 1

Claude Code Estimate (~4-5 hours wall clock)

Implementation is pattern-heavy (admin UI, tests mirror existing code), which compresses significantly.

Manual QA Effort (~2-3 days)

New feature testing (~1-2 days):

  • Configure KEY_PER_TIER bundle in admin, verify fulfilment panel switches correctly
  • Configure tier key mappings (full CRUD: create, edit, delete, validation errors)
  • Place order with KEY_PER_TIER + cumulative=True (3 tiers -> 3 keys)
  • Place order with KEY_PER_TIER + cumulative=False (3 tiers -> 1 key)
  • Verify order claim page displays tier keys with tier name headings
  • 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
  • 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 config: unchanged behavior
  • Finance calculations: unaffected (spot check with dev_place_order)
  • Stock checking for KEY_PER_PRODUCT: 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 KEY_PER_TIER from KEY_PER_PRODUCT orders. Without this, both order types look identical in BigQuery.

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 fields to BundleCommerceConfig

File: fenix/store/models/bundle_config.py (line ~169, after tax_category)

fulfilment_type = models.CharField(
    max_length=20,
    choices=FulfilmentType.choices,
    default=FulfilmentType.KEY_PER_PRODUCT,
    help_text="How keys are distributed: one key per product, or one key per tier.",
)

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 key is extracted. "
              "Only applies to KEY_PER_TIER fulfilment.",
)

Add FieldPanel("fulfilment_type") and FieldPanel("cumulative_fulfilment") to content_panels (line ~185).

Add clean() validation: if fulfilment_type == KEY_PER_TIER, check that the associated BundlePage's bundle_type is not BOOK. Note: this validation may need to happen at the BundlePage level since BundleCommerceConfig doesn't directly know the bundle type. Consider validating in the admin save flow or documenting as a constraint.

1.3 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, not per product)
  • 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.4 Orphan cleanup on BundleCommerceConfig.save()

File: fenix/store/models/bundle_config.py (line ~241, in save() and new method)

Add cleanup_stale_key_mappings() method to BundleCommerceConfig, called via transaction.on_commit alongside existing cleanup_stale_publisher_shares():

def cleanup_stale_key_mappings(self):
    """
    Remove key mappings that are orphaned due to fulfilment type changes
    or product removal from tiers.

    Three orphan scenarios:
    1. fulfilment_type is KEY_PER_PRODUCT → delete all TierKeyGroupMappingConfig
    2. fulfilment_type is KEY_PER_TIER → delete all ProductKeyGroupMappingConfig
    3. Product removed from tier → delete ProductKeyGroupMappingConfig for products
       no longer in that tier (applies regardless of fulfilment_type)
    """
    for tier in self.tiers.all():
        # Scenario 1 & 2: wrong mapping type for current fulfilment mode
        if self.fulfilment_type == FulfilmentType.KEY_PER_PRODUCT:
            tier.tier_key_mappings.all().delete()
        elif self.fulfilment_type == FulfilmentType.KEY_PER_TIER:
            tier.product_key_mappings.all().delete()

        # Scenario 3: 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()

Update save() to call both cleanups in the on_commit callback:

def _cleanup():
    config = BundleCommerceConfig.objects.filter(pk=config_pk).first()
    if config:
        config.cleanup_stale_publisher_shares()
        config.cleanup_stale_key_mappings()

This mirrors the existing cleanup_stale_publisher_shares pattern (line ~254) and addresses the orphan problem the team has seen with the current ProductKeyGroupMappingConfig.

1.5 Migration

Create migration adding:

  • fulfilment_type CharField to BundleCommerceConfig (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 - Update Order.to_protobuf() and Order.to_protobuf_refund() (lines ~404, ~439)

Add to both OrderMessage constructions:

fulfilment_type=self.fulfilment_type if hasattr(self, 'fulfilment_type') else None,

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: key_per_product or key_per_tier",
)

This field is populated during create_order from selected_tiers_data.fulfilment_type. Existing MongoDB orders without this field default to "key_per_product".


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() at 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".

3.2 Update CommerceConfigAdapter

File: fenix/store/adapters/commerce_config_adapter.py

No changes needed - the adapter delegates to queryset methods which already handle prefetching. The all() method calls for_fulfillment() which will include tier key mappings after the queryset update.


Phase 4: Order Service

4.1 Update TiersData

File: fenix/orders/services/order/service.py (find the TiersData NamedTuple)

Add fields:

tier_key_mappings: list[TierKeyGroupMappingConfig]
fulfilment_type: str  # FulfilmentType value
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 fulfilment_type and cumulative_fulfilment from the bundle's commerce config.

Access commerce config via: bundle.source_page.commerce_config.fulfilment_type

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
  • 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 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 + coupon key groups to keystone_api_service.extract_keys()
  • For non-coupon 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, ...)
  • Coupon handling remains identical

4.5 Update create_order method

File: fenix/orders/services/order/service.py (line ~620)

Add branching after selected_tiers_data = self._resolve_selected_tiers(...):

if selected_tiers_data.fulfilment_type == FulfilmentType.KEY_PER_TIER:
    tier_mappings = self._resolve_tier_key_mappings(...)
    keys = self._get_tier_keys_from_keystone(tier_mappings, coupons, metadata)
else:
    key_mappings = self._resolve_key_mappings(...)
    keys = self._get_order_keys_from_keystone(key_mappings, coupons, metadata)

Everything else (products, finance, order construction) remains unchanged. Products still exist for finance purposes regardless of fulfilment type.

4.6 Update out-of-stock handling

File: fenix/orders/services/order/service.py (line ~536)

Update _handle_out_of_stock_commerce_config to check commerce_config.fulfilment_type. If KEY_PER_TIER, update TierKeyGroupMappingConfig objects instead of ProductKeyGroupMappingConfig.


Phase 5: Admin UI

5.1 Update FulfilmentPanelConfig

File: fenix/store/panels.py (line ~10)

Update get_context_data to pass fulfilment_type to template context. If KEY_PER_TIER, iterate over tiers (not tier-product pairs) and collect tier_key_mappings instead of product_key_mappings.

5.2 Update panel template

File: fenix/store/templates/admin/fulfilment_panel_config.html

Add conditional: if fulfilment_type == "key_per_tier", render tier-level component; else render existing product-level 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, country codes, stock status, edit/delete buttons). Same HTMX pattern as product/index.html.
  • form.html: HTMX form with tier_id, key_group, 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

New classmethod:

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 _check_tier_stock_status lives)

For KEY_PER_TIER, a tier is in stock if ALL its key groups for the country are in stock (since a tier can have multiple items):

if 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

For KEY_PER_PRODUCT: existing logic unchanged (check product_key_mappings_in_stock_count).


Files Summary

File Change
fenix/store/constants.py Add FulfilmentType enum
fenix/store/models/bundle_config.py Add fields to BundleCommerceConfig, new TierKeyGroupMappingConfig model, update querysets
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 Add _resolve_tier_key_mappings, _get_tier_keys_from_keystone, update create_order, TiersData, out-of-stock handling
fenix/store/panels.py Update FulfilmentPanelConfig context for tier mode
fenix/store/templates/admin/fulfilment_panel_config.html Conditional rendering by fulfilment type
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 Stock check branching
fenix/store/adapters/commerce_config_adapter.py No changes needed

Verification

Unit Tests

# Run existing finance tests (must still pass - no finance changes)
uv run pytest fenix/orders/services/finance/tests/ -v

# Run existing order service tests (backward compat)
uv run pytest fenix/orders/services/order/tests/ -v

# Run existing fulfilment admin tests (backward compat)
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
  3. Service tests: _resolve_tier_key_mappings (cumulative/non-cumulative, missing mappings), _get_tier_keys_from_keystone (happy path, OOS), create_order with KEY_PER_TIER
  4. Admin tests: Tier mapping CRUD views, form validation
  5. Display tests: _get_tier_keys with/without tier keys

Manual Verification

  1. Create a BundleCommerceConfig with fulfilment_type=KEY_PER_TIER
  2. Add tiers with TierKeyGroupMappingConfig entries
  3. Place test order: uv run python manage.py dev_place_order
  4. Verify order in MongoDB has TierKey objects
  5. Verify order claim page displays tier keys correctly

Linting

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