Skip to content

Instantly share code, notes, and snippets.

@leepa
Created March 16, 2026 19:01
Show Gist options
  • Select an option

  • Save leepa/2928e805310c90205eb3710ef88a7ee2 to your computer and use it in GitHub Desktop.

Select an option

Save leepa/2928e805310c90205eb3710ef88a7ee2 to your computer and use it in GitHub Desktop.
Analysis: Frontend-Only Sub-Products vs Key Per Tier

Analysis: Frontend-Only Sub-Products vs Key Per Tier

Context

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.


1. Key mapping resolution requires every product in a tier to have a mapping

_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_mappings tries to extract keys for all of them (5 real products + 1 parent = 6 key extractions).
  • The 5 real products would need ProductKeyGroupMappingConfig entries, 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.

2. Finance calculations use products from the tier for revenue distribution

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

3. Non-cumulative fulfilment doesn't exist in the codebase

The current system is always cumulative. TierSelection (service.py:141):

return list(tiers)[: self.index + 1]  # includes all tiers up to selected

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

4. Backend changes are still needed either way

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_mappings to skip certain products

These are the same categories of backend change as the full plan, just without a clean data model to support them.


Query Impact

Both pages are currently well-optimised. Here's what each approach would cost.

Current: Bundle Detail Page (~10 PG queries)

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.

Current: Order Claim Page (1 Mongo + ~10 PG queries)

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.

Sub-Products: Additional Query Cost

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__tag

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

Full Plan (TierKey): Zero Additional Queries

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, snapshotted

Bundle 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

Query Count Comparison

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

Summary

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment