Uživatelé s nízkou inviteGeneration (early adopters) obdrží dvojnásobný kredit (2×) za
každý úspěšně zaplacený Stripe top-up. Logika se aplikuje serverside v ProcessTopupWebhookUseCase
při zpracování payment_intent.succeeded webhookem — bez nového veřejného endpointu.
- BOGO = “buy one, get one free”: namísto $X se připíše $2X do
ai_credit_usdaai_credit_initial_usd. Uživatel platí původní částku, kredit dostane dvojnásobný. - Early adopter = uživatel s
user.invite_generation <= config.bogo_max_generation. Výchozí práh je0(= gen 0 = founder-direct invite + waitlist invitees). Práh lze za chodu měnit editací DB záznamu vpricing_markup_configbez redeploye. inviteGenerationvýpočet (vSignUpWithInviteUseCase):- Waitlist invite nebo founder-direct invite (
issued_by_user_id IS NULL) →generation = 0 - Cascade invite →
issuer.invite_generation + 1
- Waitlist invite nebo founder-direct invite (
- Vypnutí BOGO: nastavit
bogo_max_generation = -1v DB. Protožeinvite_generationje vždy>= 0, podmínkagen <= -1nikdy nenastane — BOGO je efektivně vypnuto. - Admin UI: dedikovaný endpoint pro úpravu
bogo_max_generationv rámci této UC není implementován. Threshold se mění ručně přímou editací DB (pricing_markup_config). Viz limitations.md. Pokud bude přidán admin UI propricing_markup_configv budoucnu,bogo_max_generationse přidá jako pole formuláře. - Zpětná kompatibilita: existující top-upy (před deployem) nejsou retroaktivně
přepočítány. BOGO platí jen pro nové
payment_intent.succeededeventy od deploye dál. - Audit: webhook handler rozšíří svůj structured log o BOGO metadata
(
bogoApplied,originalAmount,creditedAmount,userGeneration,bogoThreshold) pro accounting a reporting. - Refund flow:
ProcessStripeWebhookUseCase.dispatch()aktuálně neobsahuje žádný handler propayment_intent.refundedanicharge.refunded— refund webhook je velsevětvi (log only, žádná akce). Kredit se při refundu nikdy neodečítá. Jde o záměrný out-of-scope; pokud bude refund flow přidán v budoucnu, BOGO musí být zohledněno v separátním issue (odečístcredited, neoriginal, aby invariantspent = ai_credit_initial_usd - ai_credit_usdzůstal konzistentní). - Pure BE změna: žádná FE změna není potřeba. Billing panel zobrazuje
ai_credit_usdze stávajícíGET /api/v1/users/me/usage/breakdown— ten se automaticky odrazí vyšší hodnotou. - Vyžaduje:
UC-10007(top-up flow),UC-10006(webhook infrastruktura),UC-10008(pricing_markup_configtabulka).
Sekvence — BOGO aplikace při payment_intent.succeeded
sequenceDiagram
participant Stripe
participant BE
participant DB
Note over Stripe,BE: Asynchronní webhook — nastane po úspěšném top-up (UC-10007)
Stripe->>+BE: POST /api/v1/stripe/webhook <br> event type: payment_intent.succeeded <br> Stripe-Signature: t=...,v1=...
BE->>BE: ověří Stripe-Signature (HMAC-SHA256)
alt signature invalid nebo timestamp starý > 5 min
BE-->>Stripe: 400 Bad Request
end
BE->>DB: SELECT id FROM stripe_webhook_events WHERE stripe_event_id = eventId
alt event již zpracován (idempotence)
BE-->>Stripe: 200 OK { "received": true }
end
BE->>DB: INSERT INTO stripe_webhook_events (stripe_event_id, type, payload_json, received_at)
BE->>DB: SELECT * FROM credit_topup WHERE id = metadata.topupId
alt credit_topup.status = SUCCEEDED (idempotence)
BE-->>Stripe: 200 OK { "received": true }
end
BE->>DB: SELECT invite_generation FROM users WHERE id = metadata.talkideUserId
BE->>DB: SELECT bogo_max_generation FROM pricing_markup_config (singleton)
BE->>BE: isBogo = (user.inviteGeneration <= config.bogoMaxGeneration) <br> multiplier = if (isBogo) 2 else 1 <br> credited = amount × multiplier
BE->>DB: UPDATE credit_topup SET status = SUCCEEDED, completed_at = NOW() <br> WHERE id = topupId
BE->>DB: UPDATE user_budget SET <br> ai_credit_usd = ai_credit_usd + credited, <br> ai_credit_initial_usd = ai_credit_initial_usd + credited <br> WHERE user_id = userId
BE->>BE: structured log: bogoApplied, originalAmount, creditedAmount, <br> userGeneration, bogoThreshold
BE->>-Stripe: 200 OK { "received": true }
Interní kontrakt (žádný nový HTTP endpoint)
Tato UC nepřidává nový veřejný endpoint. Mění se interní logika ProcessTopupWebhookUseCase
a datový model pricing_markup_config.
Změna v ProcessTopupWebhookUseCase.onPaymentSucceeded()
Poznámka k
PricingService:PricingService(balíčekpricing.domain) existuje, ale slouží výhradně pro markup výpočty na read-path (getEffectiveMarkup(),calculateChargedCost(),applyMarkup()). Žádná metoda probogo_max_generationvPricingServiceneexistuje.ProcessTopupWebhookUseCasePricingServicenepoužívá — tato UC proto čtebogo_max_generationpřímo přesPricingMarkupConfigRepository, analogicky ke stávajícímu přístupu ostatních write-path use caseů kpricing_markup_config. Pokud budePricingServicev budoucnu rozšířen ogetBogoMaxGeneration(): Long, lze refaktorovat — pro MVP není nutné.
PŘED:
credited = amount
budget.aiCreditUsd += credited
budget.aiCreditInitialUsd += credited
PO:
config = pricingMarkupConfigRepository.findSingleton()
isBogo = user.inviteGeneration <= config.bogoMaxGeneration
multiplier = if (isBogo) 2 else 1
credited = amount * multiplier
budget.aiCreditUsd += credited
budget.aiCreditInitialUsd += credited
log.info("topup credited", mapOf(
"topupId" to topupId,
"originalAmount" to amount,
"creditedAmount" to credited,
"bogoApplied" to isBogo,
"userGeneration" to user.inviteGeneration,
"bogoThreshold" to config.bogoMaxGeneration
))
PricingMarkupConfig entity — nový sloupec
Existující pricing_markup_config singleton tabulka (changeset 0031) dostane nový sloupec
přes changeset 0047:
ALTER TABLE pricing_markup_config
ADD COLUMN bogo_max_generation INTEGER NOT NULL DEFAULT 0;
Pre-existing rows (právě jeden singleton) dostanou default 0 = BOGO platí pro gen 0 (founder
- waitlist invitees) ihned po deployi.
PricingMarkupConfig Kotlin entita se rozšíří o:
@Column(name = "bogo_max_generation", nullable = false)
var bogoMaxGeneration: Int = 0
Pozn.:
bogo_max_generationjeINTEGER(neBIGINT) pro konzistenci susers.invite_generation INTEGERa porovnáníInt <= Intbez castingu. Generation hodnoty v praxi nepřekročí desítky.
PricingMarkupConfigRepository.findSingleton() vrátí aktualizovanou entitu — bez dalších změn
v repository vrstvě.
Datový model
erDiagram
PRICING_MARKUP_CONFIG {
bigint id
numeric ai_markup_percent
numeric hosting_markup_percent
integer bogo_max_generation
}
USERS {
bigint id
integer invite_generation
varchar stripe_customer_id
}
USER_BUDGET {
bigint id
bigint user_id
numeric ai_credit_usd
numeric ai_credit_initial_usd
}
CREDIT_TOPUP {
bigint id
bigint user_id
numeric amount_usd
varchar status
varchar stripe_payment_intent_id
timestamp completed_at
}
USERS ||--|| USER_BUDGET : "owns"
USERS ||--o{ CREDIT_TOPUP : "initiates"
PRICING_MARKUP_CONFIG ||--o{ CREDIT_TOPUP : "configures bonus for"
Liquibase changesety
| Soubor | Obsah |
|---|---|
0047-add-bogo-max-generation.yaml | ALTER TABLE pricing_markup_config ADD COLUMN bogo_max_generation INTEGER NOT NULL DEFAULT 0; |
# 0047-add-bogo-max-generation.yaml
databaseChangeLog:
- changeSet:
id: 0047-add-bogo-max-generation
author: system
changes:
- addColumn:
tableName: pricing_markup_config
columns:
- column:
name: bogo_max_generation
type: INTEGER
constraints:
nullable: false
defaultValueNumeric: 0
rollback:
- dropColumn:
tableName: pricing_markup_config
columnName: bogo_max_generation
Prod phase invariant: předchozí poslední changeset je
0046. Tento soubor přidává0047. Nikdy needituj existující changesety — Liquibase checksum validace by selhala. Singleton row vpricing_markup_configdostanebogo_max_generation = 0automaticky přesDEFAULT 0přiALTER TABLE— žádný backfill SQL není potřeba.
Business pravidla
- BOGO podmínka:
user.invite_generation <= config.bogo_max_generation. Splněna →multiplier = 2, nesplněna →multiplier = 1(standardní top-up bez bonusu). - Výchozí práh = 0: po deployi dostanou BOGO jen uživatelé gen 0 (founder-direct +
waitlist invitees). Pro rozšíření na gen 1 stačí změnit DB hodnotu na
1. - Vypnutí přes -1:
bogo_max_generation = -1je rezervovaná hodnota pro úplné vypnutí BOGO.invite_generationje vždy>= 0, takže podmínkagen <= -1nikdy nenastane. ai_credit_initial_usd= credited (ne original): initial kredit odráží skutečně připsaný kredit včetně BOGO bonusu. Invariantspent = ai_credit_initial_usd - ai_credit_usdzůstává zachován.- Idempotence: BOGO výpočet je součástí existující idempotence logiky. Duplikátní
Stripe event → webhook handler vrátí 200 bez přičtení kreditu (idempotence check na
stripe_webhook_events.stripe_event_idacredit_topup.status). - Žádná retroaktivní aplikace: top-upy provedené před deployem
0047jsou nezměněny. BOGO platí výhradně propayment_intent.succeededeventy zpracované po nasazení. pricing_markup_configsingleton musí existovat: pokud záznam neexistuje,findSingleton()vyhodí výjimku — existující error handling z UC-10008 pokryje tento případ (500 Internal Server Error + log). V praxi singleton vždy existuje od changesetu0031.- Kreditový model zachován:
spent = ai_credit_initial_usd - ai_credit_usdzůstává invariantní. BOGO nevytváří nový typ transakce — pouze mění výši přičítané částky.
Validations (BE — žádná nová FE validace)
Tato UC nemá vlastní endpoint. Existující validace webhook handleru (Stripe-Signature, idempotence) se nemění. BOGO logika přidává tyto interní runtime checks:
| Stav | Podmínka | Reakce |
|---|---|---|
pricing_markup_config singleton chybí | findSingleton() vyhodí EntityNotFoundException | 500 Internal Server Error; webhook retry Stripem |
user dle talkideUserId nenalezen | UserNotFoundException | 500 Internal Server Error; existující error handling UC-10007 |
invite_generation NULL | Nemůže nastat. Sloupec definován v 0001-create-users-table.xml jako NOT NULL DEFAULT 0; Kotlin entita UserEntity.inviteGeneration: Int = 0 (non-nullable). Žádný defenzivní null handling není potřeba. | — |
UX guidelines
Tato UC je čistě serverside — žádná FE změna. Billing panel (BillingSection.vue) automaticky
zobrazí vyšší kredit přes stávající GET /api/v1/users/me/usage/breakdown. Žádný speciální
badge, notifikace ani BOGO indikátor v UI není v scope této UC.
Test Cases
Backend
| ID | GIVEN | WHEN | THEN | Scope |
|---|---|---|---|---|
| TC-17-BE-1 | User s invite_generation = 0, bogo_max_generation = 0, top-up $50 | payment_intent.succeeded webhook zpracován | ai_credit_usd += 100, ai_credit_initial_usd += 100; BOGO aplikován (2×) | unit |
| TC-17-BE-2 | User s invite_generation = 1, bogo_max_generation = 0, top-up $50 | payment_intent.succeeded webhook zpracován | ai_credit_usd += 50, ai_credit_initial_usd += 50; BOGO NEaplikován (1×) | unit |
| TC-17-BE-3 | User s invite_generation = 1, bogo_max_generation = 1, top-up $30 | payment_intent.succeeded webhook zpracován | ai_credit_usd += 60, ai_credit_initial_usd += 60; BOGO aplikován (2×) | unit |
| TC-17-BE-4 | User s invite_generation = 5, bogo_max_generation = 0, top-up $20 | payment_intent.succeeded webhook zpracován | ai_credit_usd += 20, ai_credit_initial_usd += 20; BOGO NEaplikován (1×) | unit |
| TC-17-BE-5 | bogo_max_generation = -1, user s invite_generation = 0 | payment_intent.succeeded webhook zpracován | ai_credit_usd += amount (1×); BOGO NIKDY — threshold -1 = vypnuto | unit |
| TC-17-BE-6 | User gen 0, BOGO aplikován, top-up $50 | webhook zpracován | Structured log obsahuje bogoApplied=true, originalAmount=50, creditedAmount=100, userGeneration=0, bogoThreshold=0 | unit |
| TC-17-BE-7 | User gen 2, BOGO NEaplikován, top-up $50 | webhook zpracován | Structured log obsahuje bogoApplied=false, originalAmount=50, creditedAmount=50, userGeneration=2, bogoThreshold=0 | unit |
| TC-17-BE-8 | Nový uživatel gen 0 s nulovým výchozím kreditem, top-up $100 | payment_intent.succeeded webhook zpracován | ai_credit_usd = 200, ai_credit_initial_usd = 200; spent = initial - current = 0 (invariant zachován) | integration |
| TC-17-BE-9 | Existující user gen 0 s ai_credit_usd = 73.45, top-up $50 | payment_intent.succeeded webhook zpracován | ai_credit_usd = 173.45 (73.45 + 100); BOGO bonus správně sčítá nahoru | integration |
| TC-17-BE-10 | Stejný payment_intent.succeeded event doručen podruhé (Stripe retry), gen 0 user | webhook zpracován podruhé | 200 OK; idempotence check zastaví zpracování; ai_credit_usd beze změny (kredit připsán jen jednou) | integration |
| TC-17-BE-11 | bogo_max_generation = 0; refund Stripe eventu pro top-up $50 od gen 0 usera | refund event dorazí na webhook | OUT OF SCOPE — ProcessStripeWebhookUseCase.dispatch() neobsahuje handler pro payment_intent.refunded / charge.refunded; event spadne do else (log only). Kredit se neodečítá. Follow-up: až bude refund webhook handler přidán, musí odečíst credited (ne original) aby invariant spent = ai_credit_initial_usd - ai_credit_usd zůstal konzistentní. Otevřít separátní issue. | N/A |
Závislosti a dopady
| Závislost | Stav | Poznámka |
|---|---|---|
users.invite_generation | Existuje | Nastavováno v SignUpWithInviteUseCase |
pricing_markup_config tabulka | Existuje (changeset 0031) | Rozšiřuje se o bogo_max_generation (changeset 0047) |
ProcessTopupWebhookUseCase | Existuje | Viz UC-10007; upravuje se BOGO větev |
PricingMarkupConfigRepository.findSingleton() | Existuje | Beze změny signatury |
credit_topup tabulka | Existuje (changeset 0027) | Beze změny schématu |
stripe_webhook_events tabulka | Existuje (UC-10006) | Beze změny schématu |
FEEDBACK
Schéma credit_topup a pricing_markup_config bylo nutné odvodit z UC-10007 a UC-10008 —
oba zdroje byly konzistentní, takže žádná nejistota. Po ověření kódu jsou opraveny dva fakty:
(1) Refund flow v ProcessStripeWebhookUseCase.dispatch() neexistuje — žádný branch pro
payment_intent.refunded ani charge.refunded; TC-17-BE-11 je přerámován na out-of-scope/future.
(2) invite_generation je NOT NULL DEFAULT 0 (changeset 0001), Kotlin typ Int (non-nullable)
— defenzivní null handling odstraněn ze Validations tabulky. Zbývající otázka je
user_budget row pro nového usera (TC-17-BE-8) — předpokládá se, že row vždy existuje;
ověřit při implementaci.
Thanks for the feedback.