Model refundable orders as separate purchases; add split action
Django CI/CD / test (push) Successful in 3m41s
Staging deployment / deploy (push) Successful in 1m7s
Staging deployment / comment (push) Has been skipped
Staging deployment / teardown (push) Has been skipped
Django CI/CD / build-and-push (push) Failing after 12m17s
Django CI/CD / test (push) Successful in 3m41s
Staging deployment / deploy (push) Successful in 1m7s
Staging deployment / comment (push) Has been skipped
Staging deployment / teardown (push) Has been skipped
Django CI/CD / build-and-push (push) Failing after 12m17s
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_<id>. `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 <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 ``<template>``). Inputs inherit
|
||||
the global ``#add-form`` styling, so the markup stays minimal.
|
||||
"""
|
||||
row_template = Template(attributes=[("data-selection-fields-row", "")])[
|
||||
Div(attributes=[("data-selection-fields-row-item", "")])[
|
||||
Label(attributes=[("data-selection-fields-label", "")]),
|
||||
Input(type=field_type, attributes=list(input_attributes or [])),
|
||||
]
|
||||
]
|
||||
return _SelectionFields(
|
||||
source=source,
|
||||
name_prefix=name_prefix,
|
||||
field_type=field_type,
|
||||
min_items=min_items,
|
||||
active="true" if active else "false",
|
||||
)[
|
||||
Div(attributes=[("data-selection-fields-rows", "")]),
|
||||
row_template,
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user