Marže (ai_markup_percent, hosting_markup_percent z pricing_markup_config, C.3) se přestane jen zobrazovat a začne se reálně promítat do odečtu z kreditu a do reconciliation/quota. RAW data zůstávají immutable; charged je odvozená částka aplikovaná v okamžiku debitu/akruálu. Obě marže musí snést hodnotu 0 (vypnutá marže = chování jako dnes = žádná regrese). Model-agnostic změna — nezávislá na pre/postpaid hosting modelu (Phase-2b).
- Kontext (potvrzeno čtením kódu, viz UC-10008-PHASE1-ANALYSIS): dnes
RecordUsageEventUseCasepo zápisu usage eventu voládecrementBudgetUseCase(userId, costUsd)s RAWcostUsd.PricingService.calculateChargedCost/applyMarkupmá jediného volajícího — sám sebe — a je referencován výhradně z read/display cest (GetMyUsageUseCase,GetUsageBreakdownUseCase,Admin*Pricing*). ⇒ marže je dnes kosmetika v breakdownu, reálné peníze jedou na RAW. - Cíl této UC:
charged_amount = applyMarkup(raw, markup_percent)se reálně odečte z kreditu a stane se hodnotou, kterou reconciliation/quota porovnává jako „co user reálně utratil”. Týká se AI debit cesty teď; hosting debit/akruál cesta dostává stejný markup-aware kontrakt, ale její reálné napojení na fond/akruál řeší Phase-2b (UC-10009). - Invariant — raw zůstává raw:
usage_events(cost_usd/cost_cents) ahosting_cost_eventsse NIKDY nemutují. Markup se aplikuje až při přechodu raw → charged v okamžiku debitu. Charged + použitýmarkup_percent_snapshotse ukládá zvlášť pro auditovatelnost a re-derivaci. - Marže = 0 ⇒ žádná regrese:
applyMarkup(raw, 0) = raw * (1 + 0/100) = raw, scale 6 HALF_UP. Pokud admin nastaví obě marže na0, odečet z kreditu je bit-identický s dnešním RAW chováním. Toto je vynucený akceptační požadavek (test case níže). - Marže ≥ 0, ne > 0:
UpdatePricingMarkupUseCasevalidace musí povolit0a zakázat záporné hodnoty. Pokud dnešní validace vyžaduje> 0, mění se na>= 0. Horní mez ponechána dle stávající config (žádná nová horní mez touto UC). - Marže změněná adminem za běhu: markup se aplikuje v okamžiku debitu a do charged řádku se uloží
markup_percent_snapshotpoužité hodnoty. Již zaúčtované debety se NEPŘEPOČÍTÁVAJÍ (immutable historie). Nová admin hodnota platí pro všechny budoucí debety od okamžiku PUT. - Reconciliation/quota:
usage_eventszůstává raw single-source-of-truth pro spotřebu. Reconciliation report (UC-08005) a billing/quota pohledy (UC-08002 / UC-08006) nově rozlišují raw vs. charged: „kolik se reálně odečetlo z kreditu” = charged. Rolling Redis kvótová okna (UC-08002) se počítají z charged (= co user reálně utrácí) — viz Open Decision rozhodnuté níže (Q7). - DB migration: nový changeset
0030-add-charged-cost-to-usage-events.xml— přidává odvozené, ne-mutující sloupce nausage_events(raw zůstává nedotčen). Liquibase immutable (production phase) — nový soubor, nikdy edit existujícího. - Žádná nová Stripe webhook infra, žádný nový HTTP endpoint pro user/admin (markup config endpoint už existuje z C.3 — jen validace
>= 0). - Related: UC-08001 (gateway emituje usage events), UC-08005 (raw vs charged v reconciliation), UC-08006 (charged v billing view), UC-10009 (hosting postpaid — používá stejný markup kontrakt).
Sekvence — debit cesty s reálně aplikovanou marží
sequenceDiagram
actor User
participant FE
participant GW as AnthropicGatewayService
participant RUE as RecordUsageEventUseCase
participant PS as PricingService
participant DEC as DecrementBudgetUseCase
participant DB
User->>+FE: odešle zprávu (Mara turn)
FE->>+GW: turn (přes UC-08001)
GW->>+RUE: recordUsageEvent(userId, rawCostUsd, tokens, ...)
RUE->>DB: INSERT usage_events (cost_usd = RAW, immutable)
RUE->>+PS: applyMarkup(rawCostUsd, aiMarkupPercent)
Note over PS: charged = raw * (1 + percent/100)<br/>scale 6 HALF_UP<br/>percent = 0 ⇒ charged == raw
PS-->>-RUE: chargedCostUsd + markupPercentSnapshot
RUE->>DB: UPDATE usage_events SET charged_cost_usd, markup_percent_snapshot<br/>(odvozené sloupce, RAW cost_usd nedotčen)
RUE->>+DEC: decrementBudget(userId, chargedCostUsd)
Note over DEC: dříve: decrementBudget(userId, rawCostUsd)
DEC->>DB: UPDATE user_budget SET ai_credit_usd = ai_credit_usd - chargedCostUsd
DEC-->>-RUE: ok
RUE-->>-GW: ok
GW-->>-FE: PM odpověď
FE-->>-User: zobraz odpověď
Note over RUE,DB: Reconciliation (UC-08005) a quota okna (UC-08002)<br/>nově čtou charged_cost_usd jako "reálně odečteno".<br/>RAW cost_usd zůstává SSoT pro spotřebu.
Interní kontrakt (žádný nový HTTP endpoint)
Tato UC nepřidává nový user/admin endpoint. Mění se interní debit cesta a rozšiřuje datový model.
PricingService (existuje z C.3 — beze změny chování, ověřit 0)
// charged = raw * (1 + percent / 100), scale 6 HALF_UP
fun applyMarkup(rawCost: BigDecimal, percent: BigDecimal): BigDecimal
fun calculateChargedCost(rawCost: BigDecimal, kind: MarkupKind): ChargedResult
ChargedResult (nově vystavený do debit cesty, ne jen read):
data class ChargedResult(
val rawAmountUsd: BigDecimal, // = vstupní raw, beze změny
val chargedAmountUsd: BigDecimal, // raw * (1 + percent/100)
val markupPercentSnapshot: BigDecimal // hodnota percent v okamžiku výpočtu
)
RecordUsageEventUseCase — změna debitu
PŘED: decrementBudgetUseCase(userId, costUsd) // RAW
PO: val charged = pricingService.calculateChargedCost(costUsd, AI)
// INSERT usage_events ... cost_usd = costUsd (RAW, immutable)
// UPDATE usage_events ... charged_cost_usd = charged.chargedAmountUsd,
// markup_percent_snapshot = charged.markupPercentSnapshot
decrementBudgetUseCase(userId, charged.chargedAmountUsd) // CHARGED
UpdatePricingMarkupUseCase — validace
PŘED (předpoklad k ověření v kódu): percent musí být > 0 (nebo už >= 0)
PO: percent >= 0 (0 = marže vypnutá, dovoleno); percent < 0 → VALIDATION
DB Migration — 0030-add-charged-cost-to-usage-events.xml
ALTER TABLE usage_events
ADD COLUMN charged_cost_usd NUMERIC(20,6) NULL,
ADD COLUMN markup_percent_snapshot NUMERIC(6,3) NULL;
-- Backfill historických řádků: charged = raw (marže se zpětně NEaplikuje;
-- už zaúčtované debety se nepřepočítávají — viz invariant).
UPDATE usage_events
SET charged_cost_usd = cost_usd, markup_percent_snapshot = 0
WHERE charged_cost_usd IS NULL;
- RAW
cost_usd/cost_centszůstávají nedotčené (immutable SSoT spotřeby). charged_cost_usdnullable jen kvůli ALTER; po backfillu vždy vyplněno (nové řádky vyplňujeRecordUsageEventUseCase).- Historický backfill
charged = raw, markup = 0zajišťuje, že reconciliation nad starými řádky nehlásí drift (žádná retroaktivní marže). - Liquibase immutable (production phase): nový soubor
0030-..., nikdy edit existujícího changesetu.
POST ani jiný HTTP request se touto UC nezavádí.
200 OK na existujícím GET /api/v1/users/me/usage/breakdown (C.3) — beze změny tvaru, hodnoty charged nyní odpovídají reálně odečtené částce (dříve čistě teoretické).
400 Bad Request (validation, existující endpoint PUT /api/v1/admin/pricing/markup) ErrorResponse:
{
"code": "VALIDATION",
"message": "Markup percent must be zero or positive"
}
Frontend
Tato UC nemá vlastní FE obrazovku. Admin markup config UI (C.3) musí jen povolit zadání 0 (dnes možná blokuje > 0).
Validations
| Field | Constraints | Size | Pattern | Note |
|---|---|---|---|---|
| aiMarkupPercent | not_null, min 0 | ≥ 0 | 0 = marže vypnutá (dovoleno) | |
| hostingMarkupPercent | not_null, min 0 | ≥ 0 | 0 = marže vypnutá (dovoleno) |
Backend
Validations
| Field | Constraints | Size | Pattern | Note |
|---|---|---|---|---|
| aiMarkupPercent | not_null, min 0 | ≥ 0 | >= 0 (změna z případného > 0); záporné → VALIDATION | |
| hostingMarkupPercent | not_null, min 0 | ≥ 0 | >= 0; záporné → VALIDATION |
Test Cases
| GIVEN | WHEN | THEN |
|---|---|---|
| ai_markup_percent = 30, user má ai_credit_usd = 100 | recordUsageEvent s raw cost $1.00 | usage_events.cost_usd = 1.00 (raw), charged_cost_usd = 1.30, ai_credit_usd = 98.70 |
| ai_markup_percent = 0, user má ai_credit_usd = 100 | recordUsageEvent s raw cost $1.00 | charged_cost_usd = 1.00 = raw, ai_credit_usd = 99.00 — bit-identické s pre-change RAW chováním (žádná regrese) |
| ai_markup_percent = 30 | recordUsageEvent | usage_events.cost_usd (raw) se NEMĚNÍ; charged je v samostatném sloupci (immutable raw) |
| existují zaúčtované řádky s markup 30, admin změní markup na 50 | recordUsageEvent (nový event) | nový řádek markup_percent_snapshot = 50; staré řádky NEPŘEPOČÍTÁNY (markup_percent_snapshot = 30) |
| historický usage_events řádek z doby před touto UC | reconciliation report (UC-08005) | charged_cost_usd = cost_usd (backfill), žádný drift nehlášen |
| ai_markup_percent = 30 | reconciliation/quota čte „reálně odečteno” | čte charged_cost_usd; raw cost_usd zůstává SSoT spotřeby |
| admin nastaví markup = 0 | updatePricingMarkup | uloženo, žádná chyba (0 dovoleno) |
| admin nastaví markup = -5 | updatePricingMarkup | 400 VALIDATION (záporné zakázáno) |
PUT s vynechaným/null polem (např. {"aiMarkupPercent":25}, hosting vynechán) | updatePricingMarkup | 200 — partial update: ai=25, hosting beze změny (no-op pro null pole) |
Q (rozhodnuto — bylo Open Decision, nyní fixní pro implementaci)
- Q7 (bylo D7 ANALYSIS): quota okna z raw nebo charged? → charged. Rolling Redis kvótová okna (UC-08002) reprezentují „kolik user reálně utrácí” → musí počítat charged. Raw zůstává odděleně pro analytics/cost-side. Rationale: limit utrácení je o penězích uživatele = charged; kdyby quota běžela na raw, user s nenulovou marží by narazil na limit jinde než ukazuje billing.
- Marže 0 = no-op je akceptační invariant, ne volitelné chování — garantuje bezpečné nasazení (deploy s marží 0 = žádná změna chování; marže se „zapne” samostatným admin PUT, oddělený od deploye).
Thanks for the feedback.