The Key Per Tier plan proposes adding fulfilment_type at the BundleCommerceConfig level with a new TierKeyGroupMappingConfig model, new DTO (TierKey), service-layer branching, admin UI, and claim page updates.
An alternative idea was proposed: create "sub-products" that only exist on the frontend, so the OMS still treats everything as one product and doesn't need changes.
This document walks through what the current code actually does and where that approach runs into problems.
_resolve_key_mappings() (service.py:463-497) iterates over every product in every selected tier and looks up a ProductKeyGroupMappingConfig for the customer's country. If any product lacks one, it raises OrderCreationError.
# service.py:489-497
if len(products_without_mappings) > 0:
...
raise OrderCreationError(msg)This means there are two options for how sub-products would sit alongside the existing products:
Option A: Keep real products in the tier (for finance) and add a "parent" product for the tier key.
_resolve_key_mappingstries to extract keys for all of them (5 real products + 1 parent = 6 key extractions).- The 5 real products would need
ProductKeyGroupMappingConfigentries, but they don't have their own key groups in the KEY_PER_TIER model. You'd need dummy mappings. - Result: 6 keys extracted when you wanted 1.
Option B: Remove the real products from the tier, replace with one parent product.
- Key extraction works: 1 parent product, 1 mapping, 1 key.
- But finance calculations lose the products they need (see next section).
There's no way to tell _resolve_key_mappings to skip certain products — it processes all of them.
If you go with Option B (remove real products, use one parent), the finance system loses the data it needs:
create_order(service.py:653) calls_add_publisher_info(selected_tiers_data)using products from the selected tiersfinance_service.calculate_bundle_finance()(service.py:682) distributes revenue across products based on publisher shares- Each product must have a publisher for
_get_publisher_data_from_products()(finance/service.py:56) calculator.distribute_to_products()(calculator.py:221) allocates cent amounts per product
If the tier has 1 parent product instead of 5 real products with 5 different publishers, revenue is split for 1 publisher instead of 5. The publisher shares configured on BundleTierPublisherShareConfig reference specific publishers — if those publishers' products aren't in the tier, the shares don't resolve correctly.
assert_money_conservation() (finance/tests/test_calculator.py:19-54) verifies every cent is accounted for across all stakeholders. With the wrong products in the tier, the conservation equation won't hold.
The current system is always cumulative. TierSelection (service.py:141):
return list(tiers)[: self.index + 1] # includes all tiers up to selectedAnd _resolve_selected_tiers (service.py:297-301) collects products from all of those tiers:
for tier in selected_tiers:
selected_tiers_mappings.extend(list(tier.product_key_mappings.all()))
selected_tiers_products.extend(list(tier.tier_products.all()))There's no flag or conditional to say "only extract keys for the purchased tier." Sub-products don't address this — a cumulative_fulfilment boolean and a branch in the service layer are needed regardless of how the frontend is modelled.
Even setting aside points 1 and 2, sub-products would still require:
- Backend branching for cumulative vs non-cumulative (point 3)
- A mechanism to tell the order service which products need keys and which don't
- Changes to
_resolve_key_mappingsto skip certain products
These are the same categories of backend change as the full plan, just without a clean data model to support them.
Both pages are currently well-optimised. Here's what each approach would cost.
| Query | What | How |
|---|---|---|
| 1 | Tiers (main) | BundleTierConfig.active() with stock annotations inlined via Count() |
| 2 | Tier products + Product + thumbnail + DRM + DRM icon | Single prefetch with select_related 4 levels deep |
| 3 | Product tag links + tags | Prefetch with select_related("tag") |
| 4 | Product platforms + platform icons | Prefetch with select_related("platform__icon") |
| 5 | Thumbnail renditions | Prefetch |
| 6 | Publisher shares + publisher | Prefetch with select_related("publisher") |
| 7 | Pricing + country | Prefetch with select_related("country"), filtered by currency/country |
| +2-3 | Settings, age rating, country redirect | Mostly cached via @cache_context_calculation |
Stock checking uses Count() annotations on the main tiers query — no extra round-trips.
| Query | What | How |
|---|---|---|
| 1 (Mongo) | Full order document | collection.find_one({"_id": order_id}) |
| 2 | BundlePage + commerce_config | select_related("commerce_config") |
| 3-7 | Tiers, tier products+product+DRM, platforms, pricing | 5 batched prefetches from step 2 |
| 8 | Products for cumulative tiers | Product.objects.filter(config_tier_links__tier__in=...) with select_related("drm", "drm__icon", "thumbnail") |
| 9-10 | Product platforms, tag links | Prefetches on step 8 |
| 0 | Key-to-product matching | In-memory dict lookup: games_by_id.get(key.product_id) |
| 0 | Coupon matching | In-memory dict lookup from StreamField (already loaded) |
Key-to-product matching and DRM grouping are pure in-memory operations.
A SubProduct model (FK to Product) with its own thumbnail, description, DRM, and platforms would add:
Bundle detail page (+4-6 queries):
# Current prefetch chain in with_product_relations():
tier_products_qs = TierProductConfig.objects.select_related(
"product", "product__thumbnail", "product__drm", "product__drm__icon",
).prefetch_related(
"product__tag_links", "product__product_platforms__platform__icon",
"product__thumbnail__renditions",
)
# Sub-products would need additional prefetches:
# +1: product__sub_products (the sub-product rows themselves)
# +1: product__sub_products__thumbnail
# +1: product__sub_products__thumbnail__renditions
# +1: product__sub_products__drm + drm__icon
# +1: product__sub_products__product_platforms__platform__icon
# +1: product__sub_products__tag_links__tagThese prefetches are on a nested relation (tier_products -> product -> sub_products -> ...), where each level is a separate SELECT ... WHERE id IN (...).
Order claim page (+3-5 queries):
# After fetching products, you'd need:
sub_products = SubProduct.objects.filter(
parent_product__in=products
).select_related(
"thumbnail", "drm", "drm__icon"
).prefetch_related(
"product_platforms__platform__icon"
)Additionally, key matching in _get_games() can't distinguish which sub-product a key belongs to — the key only stores the parent product_id. You'd display all sub-products for any key that matches the parent, with no way to differentiate.
The TierKey approach snapshots display_label and display_image_url into the MongoDB order document at creation time:
class TierKey(OrderKeyBase):
tier_key: str # tier identifier
tier_name: str # "Tier 1", "Tier 2"
display_label: str # "Adobe Photoshop License"
display_image_url: str | None # CDN URL, snapshottedBundle detail page: No change. Products render as they do today. Stock annotations add 2 more Count() calls to the existing tiers query (annotations, not separate queries).
Order claim page: No additional PostgreSQL queries. _get_tier_keys() is a pure in-memory filter over order.keys:
tier_keys = [key for key in order.keys if key.kind == "TIER"]
# display_label and display_image_url are already on the key| Page | Current | Sub-Products | Full Plan (TierKey) |
|---|---|---|---|
| Bundle detail | ~10 PG queries | ~14-16 PG queries (+40-60%) | ~10 PG queries (unchanged) |
| Order claim | 1 Mongo + ~10 PG | 1 Mongo + ~13-15 PG (+30-50%) | 1 Mongo + ~10 PG (unchanged) |
| Claim key matching | In-memory O(n) | In-memory, but can't distinguish sub-products | In-memory O(n) filter by kind |
| Stock checking | 2 Count() annotations |
Same 2 annotations (sub-products have no mappings) | +2 Count() annotations on same query |
| Concern | Frontend-only sub-products | Full plan (TierKeyGroupMappingConfig) |
|---|---|---|
| Key mapping resolution | Every product needs a mapping; no skip mechanism | New _resolve_tier_key_mappings() handles tier-level mappings |
| Finance / publisher splits | Removing products from tier breaks revenue distribution | Products stay in tier for finance; fulfilment is separate |
| Non-cumulative fulfilment | No current mechanism; backend changes still needed | cumulative_fulfilment boolean on config with service branching |
| Bundle page queries | +4-6 prefetches (~14-16 total) | Unchanged (~10 total) |
| Claim page queries | +3-5 prefetches (~13-15 total), can't distinguish keys per sub-product | Unchanged (~10 total), display data snapshotted on order |
| Backend work saved | Still need service-layer changes for cumulative control + key skipping | N/A |