From 733da3419b47b9c96ba09ed11e1758a9d632d022 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Fri, 19 Jun 2026 07:42:44 +0200 Subject: [PATCH] Model refundable orders as separate purchases; add split action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A multi-game Purchase is now treated as an *unsplittable* bundle (one price, whole-purchase refund). Independently-refundable multi-item orders (e.g. a Steam cart) are instead recorded as N separate single-game purchases, so per-game pricing and per-game refunds work with the existing single-purchase machinery — no through-model needed. Add-purchase form (single form, single endpoint): - 1 game: unchanged. - 2+ games: a "Separate price per game" toggle appears (default off = one bundle price). On, the bundle Price hides and one price input per game appears; the view creates one single-game Purchase each from price_for_game_. `price` is now optional so combined mode still validates. Split action: - A Split button on multi-game purchase rows opens a confirmation modal that replaces the bundle with one single-game purchase per game (price split evenly, needs_price_update set), then HX-Redirects to the list. New general-purpose `selection-fields` custom element renders one synced form field per selected item of a source SearchSelect (consuming the existing search-select:change contract); it knows nothing about prices, so it is reusable. Behavior in ts/elements/selection-fields.ts. Adds the bundle-vs-separate-purchases convention to CLAUDE.md, a split icon, and unit + Playwright e2e coverage. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 2 +- common/components/__init__.py | 7 +- common/components/custom_elements.py | 58 ++++++- e2e/test_purchase_e2e.py | 178 +++++++++++++++++++++ games/forms.py | 8 + games/static/js/add_purchase.js | 34 ++++ games/templates/icons/split.html | 5 + games/urls.py | 10 ++ games/views/purchase.py | 208 ++++++++++++++++++++++++- tests/test_purchase_separate_orders.py | 108 +++++++++++++ ts/elements/selection-fields.ts | 100 ++++++++++++ 11 files changed, 711 insertions(+), 7 deletions(-) create mode 100644 e2e/test_purchase_e2e.py create mode 100644 games/templates/icons/split.html create mode 100644 tests/test_purchase_separate_orders.py create mode 100644 ts/elements/selection-fields.ts diff --git a/CLAUDE.md b/CLAUDE.md index 5c58a5f..b9a739c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,7 @@ docs/ — Additional documentation - **Game** — `name`, `platform` (FK), `status` (u/p/f/r/a), `mastered`, `playtime` (DurationField updated via signal), `year_released`, `sort_name`, `wikidata` - **Platform** — `name`, `group`, `icon` (slug, auto-generated from name) -- **Purchase** — ownership type, prices, currency conversion (`converted_price`, `price_per_game` is a `GeneratedField`), links to Game via M2M. `num_purchases` counts linked games. DLC/SeasonPass/BattlePass must have a `related_game` (the base Game the add-on belongs to; reverse accessor `game.addon_purchases`) +- **Purchase** — ownership type, prices, currency conversion (`converted_price`, `price_per_game` is a `GeneratedField`), links to Game via M2M. `num_purchases` counts linked games. DLC/SeasonPass/BattlePass must have a `related_game` (the base Game the add-on belongs to; reverse accessor `game.addon_purchases`). **A multi-game Purchase is an *unsplittable* bundle** (one price, whole-purchase refund — e.g. a Humble Bundle). Independently-refundable multi-item orders (e.g. a Steam cart) are modeled as **separate single-game purchases**, not one bundle: the add-purchase form's "separate price per game" mode (≥2 games) creates them, and the row's **Split** action breaks an existing bundle into per-game purchases (price split evenly as a starting point). This is why per-game refund/price need no through-model — each refundable unit is its own Purchase. - **Session** — `timestamp_start`/`timestamp_end`, `duration_manual`, `device` (FK), `note`, `emulated`. `duration_calculated` and `duration_total` are `GeneratedField`s (cannot be written directly) - **Device** — `name`, `type` (PC/Console/Handheld/Mobile/SBC/Unknown) - **PlayEvent** — marks when a game was started/finished (separate from Sessions), `days_to_finish` is a `GeneratedField` diff --git a/common/components/__init__.py b/common/components/__init__.py index a97c062..945a970 100644 --- a/common/components/__init__.py +++ b/common/components/__init__.py @@ -18,7 +18,11 @@ from common.components.core import ( randomid, render, ) -from common.components.custom_elements import SessionTimestampButtons, register_element +from common.components.custom_elements import ( + SelectionFields, + SessionTimestampButtons, + register_element, +) from common.components.date_range_picker import ( DateRangeCalendar, DateRangeField, @@ -94,6 +98,7 @@ __all__ = [ "truncate", "BaseComponent", "register_element", + "SelectionFields", "SessionTimestampButtons", "custom_element_builder", "Element", diff --git a/common/components/custom_elements.py b/common/components/custom_elements.py index 1ae485a..172777e 100644 --- a/common/components/custom_elements.py +++ b/common/components/custom_elements.py @@ -11,8 +11,14 @@ reader so drift fails ``tsc``. from dataclasses import dataclass from typing import TypedDict, get_type_hints -from common.components.core import Media -from common.components.primitives import custom_element_builder +from common.components.core import Node +from common.components.primitives import ( + Div, + Input, + Label, + Template, + custom_element_builder, +) @dataclass(frozen=True) @@ -125,3 +131,51 @@ _GameStatusSelector = custom_element_builder("game-status-selector") _SessionDeviceSelector = custom_element_builder("session-device-selector") _PlayEventRow = custom_element_builder("play-event-row") SessionTimestampButtons = custom_element_builder("session-timestamp-buttons") + + +class SelectionFieldsProps(TypedDict): + source: str # data-name of the source SearchSelect to mirror + name_prefix: str # each rendered input is named f"{name_prefix}{item_id}" + field_type: str # input type, e.g. "number" + min_items: int # render nothing until at least this many items are selected + active: bool # when false, render nothing (but preserve typed values) + + +register_element("selection-fields", "SelectionFields", SelectionFieldsProps) + +_SelectionFields = custom_element_builder("selection-fields") + + +def SelectionFields( + *, + source: str, + name_prefix: str, + field_type: str = "text", + min_items: int = 1, + active: bool = False, + input_attributes: list[tuple[str, str]] | None = None, +) -> Node: + """Render one synced form field per selected item of a source SearchSelect. + + General-purpose: it mirrors the SearchSelect named ``source`` and emits an + input named ``f"{name_prefix}{item_id}"`` per selected item. Behavior lives + in ``ts/elements/selection-fields.ts``; this is just the server-rendered + light DOM (an empty rows container + a row ``