Internal Documentation internal
TalkIDE internal documentation

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_usd a ai_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 je 0 (= gen 0 = founder-direct invite + waitlist invitees). Práh lze za chodu měnit editací DB záznamu v pricing_markup_config bez redeploye.
  • inviteGeneration výpočet (v SignUpWithInviteUseCase):
    • Waitlist invite nebo founder-direct invite (issued_by_user_id IS NULL) → generation = 0
    • Cascade invite → issuer.invite_generation + 1
  • Vypnutí BOGO: nastavit bogo_max_generation = -1 v DB. Protože invite_generation je vždy >= 0, podmínka gen <= -1 nikdy nenastane — BOGO je efektivně vypnuto.
  • Admin UI: dedikovaný endpoint pro úpravu bogo_max_generation v 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 pro pricing_markup_config v budoucnu, bogo_max_generation se 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.succeeded eventy 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 pro payment_intent.refunded ani charge.refunded — refund webhook je v else vě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číst credited, ne original, aby invariant spent = ai_credit_initial_usd - ai_credit_usd zůstal konzistentní).
  • Pure BE změna: žádná FE změna není potřeba. Billing panel zobrazuje ai_credit_usd ze 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_config tabulka).

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íček pricing.domain) existuje, ale slouží výhradně pro markup výpočty na read-path (getEffectiveMarkup(), calculateChargedCost(), applyMarkup()). Žádná metoda pro bogo_max_generation v PricingService neexistuje. ProcessTopupWebhookUseCase PricingService nepoužívá — tato UC proto čte bogo_max_generation přímo přes PricingMarkupConfigRepository, analogicky ke stávajícímu přístupu ostatních write-path use caseů k pricing_markup_config. Pokud bude PricingService v budoucnu rozšířen o getBogoMaxGeneration(): 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_generation je INTEGER (ne BIGINT) pro konzistenci s users.invite_generation INTEGER a porovnání Int <= Int bez 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

SouborObsah
0047-add-bogo-max-generation.yamlALTER 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 v pricing_markup_config dostane bogo_max_generation = 0 automaticky přes DEFAULT 0 při ALTER TABLE — žádný backfill SQL není potřeba.


Business pravidla

  1. BOGO podmínka: user.invite_generation <= config.bogo_max_generation. Splněna → multiplier = 2, nesplněna → multiplier = 1 (standardní top-up bez bonusu).
  2. 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.
  3. Vypnutí přes -1: bogo_max_generation = -1 je rezervovaná hodnota pro úplné vypnutí BOGO. invite_generation je vždy >= 0, takže podmínka gen <= -1 nikdy nenastane.
  4. ai_credit_initial_usd = credited (ne original): initial kredit odráží skutečně připsaný kredit včetně BOGO bonusu. Invariant spent = ai_credit_initial_usd - ai_credit_usd zůstává zachován.
  5. 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_id a credit_topup.status).
  6. Žádná retroaktivní aplikace: top-upy provedené před deployem 0047 jsou nezměněny. BOGO platí výhradně pro payment_intent.succeeded eventy zpracované po nasazení.
  7. pricing_markup_config singleton 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 changesetu 0031.
  8. Kreditový model zachován: spent = ai_credit_initial_usd - ai_credit_usd zů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:

StavPodmínkaReakce
pricing_markup_config singleton chybífindSingleton() vyhodí EntityNotFoundException500 Internal Server Error; webhook retry Stripem
user dle talkideUserId nenalezenUserNotFoundException500 Internal Server Error; existující error handling UC-10007
invite_generation NULLNemůž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

IDGIVENWHENTHENScope
TC-17-BE-1User s invite_generation = 0, bogo_max_generation = 0, top-up $50payment_intent.succeeded webhook zpracovánai_credit_usd += 100, ai_credit_initial_usd += 100; BOGO aplikován (2×)unit
TC-17-BE-2User s invite_generation = 1, bogo_max_generation = 0, top-up $50payment_intent.succeeded webhook zpracovánai_credit_usd += 50, ai_credit_initial_usd += 50; BOGO NEaplikován (1×)unit
TC-17-BE-3User s invite_generation = 1, bogo_max_generation = 1, top-up $30payment_intent.succeeded webhook zpracovánai_credit_usd += 60, ai_credit_initial_usd += 60; BOGO aplikován (2×)unit
TC-17-BE-4User s invite_generation = 5, bogo_max_generation = 0, top-up $20payment_intent.succeeded webhook zpracovánai_credit_usd += 20, ai_credit_initial_usd += 20; BOGO NEaplikován (1×)unit
TC-17-BE-5bogo_max_generation = -1, user s invite_generation = 0payment_intent.succeeded webhook zpracovánai_credit_usd += amount (1×); BOGO NIKDY — threshold -1 = vypnutounit
TC-17-BE-6User gen 0, BOGO aplikován, top-up $50webhook zpracovánStructured log obsahuje bogoApplied=true, originalAmount=50, creditedAmount=100, userGeneration=0, bogoThreshold=0unit
TC-17-BE-7User gen 2, BOGO NEaplikován, top-up $50webhook zpracovánStructured log obsahuje bogoApplied=false, originalAmount=50, creditedAmount=50, userGeneration=2, bogoThreshold=0unit
TC-17-BE-8Nový uživatel gen 0 s nulovým výchozím kreditem, top-up $100payment_intent.succeeded webhook zpracovánai_credit_usd = 200, ai_credit_initial_usd = 200; spent = initial - current = 0 (invariant zachován)integration
TC-17-BE-9Existující user gen 0 s ai_credit_usd = 73.45, top-up $50payment_intent.succeeded webhook zpracovánai_credit_usd = 173.45 (73.45 + 100); BOGO bonus správně sčítá nahoruintegration
TC-17-BE-10Stejný payment_intent.succeeded event doručen podruhé (Stripe retry), gen 0 userwebhook 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-11bogo_max_generation = 0; refund Stripe eventu pro top-up $50 od gen 0 userarefund event dorazí na webhookOUT OF SCOPEProcessStripeWebhookUseCase.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ávislostStavPoznámka
users.invite_generationExistujeNastavováno v SignUpWithInviteUseCase
pricing_markup_config tabulkaExistuje (changeset 0031)Rozšiřuje se o bogo_max_generation (changeset 0047)
ProcessTopupWebhookUseCaseExistujeViz UC-10007; upravuje se BOGO větev
PricingMarkupConfigRepository.findSingleton()ExistujeBeze změny signatury
credit_topup tabulkaExistuje (changeset 0027)Beze změny schématu
stripe_webhook_events tabulkaExistuje (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.

Was this page helpful?

Thanks for the feedback.