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.
- No enum: There is no
fulfilment_typefield. Both product-level (ProductKeyGroupMappingConfig) and tier-level (TierKeyGroupMappingConfig) key mappings are optional on any tier. The system simply extracts keys from whatever is configured. - Validation at publish time: When a bundle goes on sale, validate that every tier has at least one key mapping (product-level OR tier-level). This prevents misconfiguration without over-constraining the admin during setup.
- Cumulative:
cumulative_fulfilmentboolean onBundleCommerceConfig(bundle level) controls whether customer gets keys for all tiers up to purchased (True) or just the purchased tier (False). - Bundle types: Tier key mappings allowed for GAME and SOFTWARE only (not BOOK) — validate at publish time.
- Display: Each tier key mapping has a required
display_labeland optionaldisplay_imagefor claim page UX. - Mixed orders: A single order can contain both
GameKeyandTierKeyobjects. The order service just processes whatever mappings exist — no branching on a type field.
- No enum field, no conditional branching in service/admin/templates based on a type
- Both mapping types are always available — admin configures whichever they need
- The order service collects product mappings → extracts GameKeys, collects tier mappings → extracts TierKeys, combines both
- Validation is deferred to a natural gate (publish/go-on-sale), not enforced during drafting
- Stock checking just checks both mapping types
| Area | Days | Notes |
|---|---|---|
| Models + migration | 0.5 | Straightforward, follows existing patterns |
| DTOs + protobuf | 0.5 | TierKey class, union update, proto field |
| Service layer | 1 | Simpler than original — no branching on type, just process both mapping types |
| Admin UI (views, forms, templates) | 1.5 | Pattern-following — 4 CBVs, form, 2 Cotton templates, URLs |
| Frontend display (order claim) | 0.5 | New template + context plumbing |
| Stock checking | 0.5 | Queryset annotations for both mapping types |
| Publish validation | 0.5 | Validate mappings exist at go-on-sale gate |
| Tests | 2-3 | DTO backward compat, service (mixed, cumulative/non-cumulative, OOS), admin CRUD, display, validation |
| Code review + iteration | 1 |
New feature testing (~1-2 days):
- Add tier key mappings to a tier in admin (full CRUD: create, edit, delete, validation errors)
- Place order with tier key mappings only (no product key mappings) + cumulative=True
- Place order with tier key mappings only + cumulative=False (→ only purchased tier's keys)
- Place order with MIXED tiers (some with product mappings, some with tier mappings, some with both)
- 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 for tier key mappings (Keystone OOS → tier mapping marked, email sent)
- Test stock annotations on bundle display page for both mapping types
- Test publish validation: tier with zero mappings of either type blocks publish
- Test BOOK bundle type rejection for tier key mappings at publish validation
Regression testing (~1-2 days):
- Existing bundles (product key mappings only): full order flow unchanged
- Existing orders in MongoDB: still display correctly on claim page (backward compat with GameKey)
- Admin fulfilment panel: product mapping section unchanged
- Finance calculations: unaffected (spot check with
dev_place_order) - Stock checking for product key mappings: unchanged annotations
- Out-of-stock flow for product key mappings: unchanged
- 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.
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/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 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():
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()No cleanup needed for TierKeyGroupMappingConfig — tier key mappings don't reference products, so they don't go stale when products change.
File: Wherever bundle publish/go-on-sale validation lives (likely BundlePage or BundleCommerceConfig clean/validation)
When a bundle goes on sale, validate that every active tier has at least one key mapping:
tier.product_key_mappings.exists()ORtier.tier_key_mappings.exists()- If a tier has
TierKeyGroupMappingConfigentries and the bundle is type BOOK, reject
This is the gate that catches misconfiguration — during drafting, admins are free to set up mappings in any order.
Create migration adding:
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.
No proto changes needed — the key type distinction is captured in MongoDB via the key shape (product_id vs tier_key/tier_name fields). If reporting needs arise later, a proto field can be added.
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().
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 - If
cumulative_fulfilment=False, filter to only the purchased tier (last in list) - For each tier that HAS tier key mappings, find ALL
TierKeyGroupMappingConfigentries matching the customer's country - Returns
list[tuple[BundleTierConfig, TierKeyGroupMappingConfig]](tier + mapping pairs) - Does NOT raise if a tier has zero tier key mappings — that tier just uses product key mappings instead
Country code overlap between different key groups within the same tier is expected — a customer receives keys from ALL key groups configured for their country.
The existing method already handles products that don't have mappings (raises OrderCreationError). Products in tiers that use tier key mappings instead would need to be excluded. Two options:
Option A: Only resolve product key mappings for products that actually have ProductKeyGroupMappingConfig entries. Skip products without them (they're covered by tier key mappings).
Option B: Exclude products from tiers that have any TierKeyGroupMappingConfig entries.
Option A is simpler and more flexible — a tier could have some products with product mappings and a tier key mapping for the rest. Modify the "products without mappings" error to only raise if a product has neither product key mapping NOR its tier has a tier key mapping.
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)
Process both mapping types — no branching on a type field, just process whatever exists:
selected_tiers_data = self._resolve_selected_tiers(bundle, request.tier_selection)
coupons = bundle.get_coupons()
metadata = { ... }
all_keys: list[OrderKey] = []
# 1. Extract product-level keys (from tiers with product key mappings)
has_product_mappings = bool(selected_tiers_data.mappings)
if has_product_mappings:
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 tiers with tier key mappings)
has_tier_mappings = bool(selected_tiers_data.tier_key_mappings)
if has_tier_mappings:
tier_mappings = self._resolve_tier_key_mappings(selected_tiers_data, ...)
tier_keys = self._get_tier_keys_from_keystone(
tier_mappings, coupons if not has_product_mappings 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 how keys are extracted.
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:
def _handle_out_of_stock_commerce_config(self, key_groups, bundle, order_id):
commerce_config = bundle.source_page.commerce_config
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)
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 also prefetch and pass tier_key_mappings per tier alongside the existing product mappings:
tiers = self.instance.tiers.prefetch_related(
"tier_products__product", "product_key_mappings", "tier_key_mappings"
)
for tier in tiers:
# Existing product-level mappings (unchanged)
for tier_product in tier.tier_products.all():
...
# NEW: tier-level mappings
tier_data.append({
"tier": tier,
"tier_is_draft": not tier.id,
"tier_key_mappings": list(tier.tier_key_mappings.all()),
"config_id": self.instance.uuid,
})File: fenix/store/templates/admin/fulfilment_panel_config.html
Show BOTH sections per tier — always. Product key mappings section (existing, unchanged) followed by a new tier key mappings section. No conditional logic needed.
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)
A tier is in stock if ALL its configured key groups (both types) are in stock for the customer's country:
# Existing product-level stock (unchanged)
product_total = getattr(tier, "product_key_mappings_for_country_count", 0)
product_in_stock = getattr(tier, "product_key_mappings_in_stock_count", 0)
# New tier-level stock
tier_total = getattr(tier, "tier_key_mappings_for_country_count", 0)
tier_in_stock = getattr(tier, "tier_key_mappings_in_stock_count", 0)
total = product_total + tier_total
in_stock = product_in_stock + tier_in_stock
has_mappings = total > 0
is_in_stock = has_mappings and in_stock == totalNo branching on a type field — just sum both mapping types.
| File | Change |
|---|---|
fenix/store/models/bundle_config.py |
cumulative_fulfilment on BundleCommerceConfig, new TierKeyGroupMappingConfig model, update querysets, cleanup logic, publish validation |
fenix/orders/services/order/dto.py |
Add TierKey, update OrderKey union |
fenix/orders/services/order/service.py |
Update TiersData, add _resolve_tier_key_mappings, _get_tier_keys_from_keystone, update create_order to process both mapping types, update out-of-stock handling |
fenix/store/panels.py |
Update FulfilmentPanelConfig to include tier key mappings |
fenix/store/templates/admin/fulfilment_panel_config.html |
Add tier key mappings section per tier |
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, publish validation (BOOK rejection, missing mappings) - Service tests:
_resolve_tier_key_mappings(cumulative/non-cumulative),_get_tier_keys_from_keystone(happy path, OOS),create_orderwith mixed mapping types,create_orderwith tier mappings only,create_orderwith product mappings only (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 tier key groups, OOS for mixed bundles
- Create a
BundleCommerceConfigwith tiers using both mapping types - Add tier key mappings to some tiers, product key mappings to others (or both to a tier)
- 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
- Verify publish validation catches tiers with no mappings at all
uv run ruff format .
uv run ruff check --fix .
uv run djlint --reformat .
uv run mypy .