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.
- Scope:
fulfilment_typelives onBundleCommerceConfig(bundle level, not per-tier) - Cumulative: Config boolean
cumulative_fulfilmentcontrols 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_labeland optionaldisplay_imagefor claim page UX. Avoids the problem of customers receiving multiple keys with no way to tell them apart.
| 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 |
Implementation is pattern-heavy (admin UI, tests mirror existing code), which compresses significantly.
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_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 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
- 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 KEY_PER_TIER from KEY_PER_PRODUCT orders. Without this, both order types look identical in BigQuery.
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 (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.
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 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 (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.
Create migration adding:
fulfilment_typeCharField toBundleCommerceConfig(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 - 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".
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".
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.
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: 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 fulfilment_type and cumulative_fulfilment from the bundle's commerce config.
Access commerce config via: bundle.source_page.commerce_config.fulfilment_type
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
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 (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 + 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
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.
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.
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.
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.
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 asproduct/index.html.form.html: HTMX form withtier_id,key_group,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
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"
]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 _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 stockFor KEY_PER_PRODUCT: existing logic unchanged (check product_key_mappings_in_stock_count).
| 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 |
# 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- DTO tests:
TierKeyconstruction,OrderKeyunion deserialization (all 3 types), backward compat with existing MongoDB docs - Model tests:
TierKeyGroupMappingConfigCRUD, validation, constraints - Service tests:
_resolve_tier_key_mappings(cumulative/non-cumulative, missing mappings),_get_tier_keys_from_keystone(happy path, OOS),create_orderwith KEY_PER_TIER - Admin tests: Tier mapping CRUD views, form validation
- Display tests:
_get_tier_keyswith/without tier keys
- Create a
BundleCommerceConfigwithfulfilment_type=KEY_PER_TIER - Add tiers with
TierKeyGroupMappingConfigentries - Place test order:
uv run python manage.py dev_place_order - Verify order in MongoDB has
TierKeyobjects - Verify order claim page displays tier keys correctly
uv run ruff format .
uv run ruff check --fix .
uv run mypy .