Internal Documentation internal
TalkIDE internal documentation

Authenticated user dobije svůj AI kredit jednorázovou platbou zvolenou částkou (USD) přes Stripe PaymentIntent s uloženou kartou. Kredit je připsán asynchronně po potvrzení Stripe webhookem. Vyžaduje platný JWT access token a registrovanou platební kartu (UC-10001).

  • Zvolený flow: PaymentIntent + uložená karta (on-session confirm) — ne Stripe Checkout Session. Důvod: uživatel má kartu již registrovanou ze UC-10001, stripe_customer_id i default_payment_method jsou na Stripe Customer uloženy. stripe.paymentIntents.create() s confirm: true + payment_method: defaultPm okamžitě potvrdí platbu bez redirect na hosted Stripe stránku. Výsledkem je čistý in-app UX bez opuštění aplikace.
  • PaymentIntent lifecycle: BE vytvoří PaymentIntent se stavem requires_confirmation nebo rovnou succeeded (záleží na 3DS). FE dostane clientSecret a zavolá stripe.confirmCardPayment(clientSecret) — Stripe SDK ošetří případnou 3DS autentizaci (challenge flow). Po úspěchu Stripe doručí payment_intent.succeeded webhook → BE idempotentně připíše kredit.
  • Metadata předání userId: při vytvoření PaymentIntent BE nastaví metadata: { talkideUserId: userId }. Webhook handler mapuje event.data.object.metadata.talkideUserId → user. Zároveň ukládá stripe_payment_intent_id do credit_topup záznamu pro druhý způsob mapování (DB lookup).
  • Idempotence: Stripe může doručit webhook vícekrát. BE nejdříve ověří stripe_webhook_events.stripe_event_id (UC-10006 sdílená tabulka), pak zkontroluje credit_topup.stripe_payment_intent_id status — pokud je SUCCEEDED, vrátí 200 bez úpravy user_budget.
  • Kreditový model: user_budget.ai_credit_usd += amountUsd, user_budget.ai_credit_initial_usd += amountUsd. “Spotřebováno” zůstává invariantní vůči dobitím: spent = ai_credit_initial_usd - ai_credit_usd.
  • Limity částky: min. $5, max. $500 (USD). Konfigurováno přes talkide.billing.topup.min-amount-usd a talkide.billing.topup.max-amount-usd v application.yaml. Měna vždy USD (kreditový model je v USD). Celá čísla (bez centů) v MVP.
  • Stripe test mode — žádné reálné peníze. Testovací karta 4242 4242 4242 4242, 3DS karta 4000 0025 0000 3155.
  • Záznam o dobití: tabulka credit_topup (migrace 0027) — stav PENDING → SUCCEEDED / FAILED. FE může pollovat GET /api/v1/billing/topup/{id}/status pro zobrazení stavu po potvrzení Stripem.
  • Předpokládá existenci výchozí platební metody na Stripe Customer (viz UC-10001). Pokud uživatel nemá kartu, BE vrátí 422.
  • Related: UC-10001 — registrace karty. UC-10006 — sdílená webhook infrastruktura.
sequenceDiagram
    actor User

    User->>+FE: otevře Billing → klikne "Add credit"

    FE->>FE: zobrazí dialog s amount inputem <br> (min $5, max $500, celá čísla)

    User->>FE: zadá částku a klikne "Pay now"

    FE->>FE: validace: positive integer, min 5, max 500
    alt vstup nevalidní
        FE-->>User: zobrazí inline chybovou zprávu
    end

    FE->>+BE: POST /api/v1/users/me/billing/topup <br> Authorization: Bearer {accessToken} <br> TopupRequest

    BE->>BE: ověří JWT access token
    alt access token invalid nebo chybí
        BE-->>FE: 401 Unauthorized <br> ErrorResponse
    end

    BE->>BE: validace amountUsd (min/max dle konfigurace)
    alt amountUsd mimo rozsah
        BE-->>FE: 400 Bad Request <br> ErrorResponse
    end

    BE->>DB: SELECT stripe_customer_id FROM users WHERE id = userId

    BE->>Stripe: stripe.customers.retrieve(customerId) <br> → zjistí default_payment_method

    alt žádná výchozí platební metoda
        BE-->>FE: 422 Unprocessable Entity <br> ErrorResponse (NO_PAYMENT_METHOD)
    end

    BE->>DB: INSERT INTO credit_topup (user_id, amount_usd, status=PENDING, created_at)

    BE->>Stripe: stripe.paymentIntents.create({ <br>   amount: amountUsd * 100, currency: "usd", <br>   customer: customerId, <br>   payment_method: defaultPmId, <br>   confirm: false, <br>   metadata: { talkideUserId: userId, topupId: topupId } <br> })
    Stripe-->>BE: PaymentIntent { id: "pi_...", client_secret: "pi_...secret_..." }

    BE->>DB: UPDATE credit_topup SET stripe_payment_intent_id = "pi_..." WHERE id = topupId

    BE->>-FE: 200 OK <br> TopupInitResponse

    FE->>Stripe: stripe.confirmCardPayment(clientSecret)

    alt 3DS challenge vyžadován
        Stripe-->>FE: vykreslí 3DS iframe / redirect
        User->>Stripe: dokončí 3DS autentizaci
        Stripe-->>FE: { paymentIntent: { status: "succeeded" } }
    end

    alt platba zamítnuta kartou
        Stripe-->>FE: { error: { code: "card_declined", message: "..." } }
        FE-->>User: zobrazí Stripe error message ("Your card was declined.")
    end

    FE->>-User: zobrazí "Payment submitted — credit will appear shortly" <br> + nabídne tlačítko pro refresh stavu

    Note over Stripe,BE: Asynchronní — Stripe doručí webhook

    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)

    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->>BE: načte metadata.talkideUserId + metadata.topupId z PaymentIntent

    BE->>DB: SELECT * FROM credit_topup WHERE id = topupId

    alt credit_topup.status = SUCCEEDED (idempotence)
        BE-->>Stripe: 200 OK { "received": true }
    end

    BE->>DB: UPDATE credit_topup SET status = SUCCEEDED, completed_at = NOW() WHERE id = topupId
    BE->>DB: UPDATE user_budget SET <br>   ai_credit_usd = ai_credit_usd + amountUsd, <br>   ai_credit_initial_usd = ai_credit_initial_usd + amountUsd <br>   WHERE user_id = userId

    BE->>-Stripe: 200 OK { "received": true }

Step 1 — Initiate Top-up

POST /api/v1/users/me/billing/topup TopupRequest:

{
  "amountUsd": 50
}

200 OK TopupInitResponse:

{
  "data": {
    "topupId": 42,
    "clientSecret": "pi_1PqXyz...secret_...",
    "amountUsd": 50,
    "status": "PENDING"
  }
}

400 Bad Request (validace — částka mimo rozsah) ErrorResponse:

{
  "status": 400,
  "code": "VALIDATION_ERROR",
  "message": "Validation failed",
  "errors": [
    { "field": "amountUsd", "message": "must be between 5 and 500" }
  ]
}

401 Unauthorized ErrorResponse:

{
  "status": 401,
  "code": "AUTHENTICATION_FAILED",
  "message": "Access token is missing or invalid"
}

422 Unprocessable Entity (žádná platební metoda) ErrorResponse:

{
  "status": 422,
  "code": "NO_PAYMENT_METHOD",
  "message": "No default payment method found. Please register a card first."
}

502 Bad Gateway (Stripe API nedostupné) ErrorResponse:

{
  "status": 502,
  "code": "STRIPE_UNAVAILABLE",
  "message": "Payment provider is temporarily unavailable. Please try again."
}

Step 2 — FE confirms payment via Stripe.js

FE zavolá stripe.confirmCardPayment(clientSecret). Toto je čistě client-side krok — žádný BE endpoint. Stripe SDK ošetří 3DS challenge pokud vyžadováno. Výsledkem je buď:

  • { paymentIntent: { status: "succeeded" } } — platba prošla, FE zobrazí “Payment submitted” a může pollovat status
  • { error: { ... } } — FE zobrazí Stripe error message (lokalizovaný Stripem)

Step 3 — Poll top-up status (optional)

GET /api/v1/users/me/billing/topup/{topupId}/status (no request body; authentication via JWT Bearer token)

200 OK TopupStatusResponse (pending):

{
  "data": {
    "topupId": 42,
    "amountUsd": 50,
    "status": "PENDING",
    "createdAt": "2026-05-16T14:00:00Z",
    "completedAt": null
  }
}

200 OK TopupStatusResponse (succeeded):

{
  "data": {
    "topupId": 42,
    "amountUsd": 50,
    "status": "SUCCEEDED",
    "createdAt": "2026-05-16T14:00:00Z",
    "completedAt": "2026-05-16T14:00:03Z"
  }
}

401 Unauthorized ErrorResponse:

{
  "status": 401,
  "code": "AUTHENTICATION_FAILED",
  "message": "Access token is missing or invalid"
}

403 Forbidden (topupId nepatří přihlášenému uživateli) ErrorResponse:

{
  "status": 403,
  "code": "FORBIDDEN",
  "message": "Access denied"
}

404 Not Found (topupId neexistuje) ErrorResponse:

{
  "status": 404,
  "code": "NOT_FOUND",
  "message": "Top-up record not found"
}

Webhook Handler — payment_intent.succeeded

Nová větev v StripeWebhookController (stávající endpoint POST /api/v1/stripe/webhook).

Vstup: Stripe event payment_intent.succeeded, PaymentIntent objekt v event.data.object.

Zpracování:

  1. Idempotence check přes stripe_webhook_events.stripe_event_id (sdílená logika UC-10006 — vrátit 200 pokud duplicita).
  2. Uložit do stripe_webhook_events.
  3. Načíst metadata.topupId a metadata.talkideUserId z event.data.object.metadata.
  4. Načíst credit_topup řádek dle topupId — pokud status = SUCCEEDED, return 200 (idempotence na úrovni top-up záznamu).
  5. V DB transakci: UPDATE credit_topup SET status = SUCCEEDED, completed_at = NOW(), pak UPDATE user_budget SET ai_credit_usd += amountUsd, ai_credit_initial_usd += amountUsd.
  6. Vrátit 200 OK { "received": true }.

payment_intent.payment_failed (volitelná větev):

  1. Idempotence check (stejný postup).
  2. Uložit do stripe_webhook_events.
  3. UPDATE credit_topup SET status = FAILED WHERE id = topupId.
  4. Log na WARN úrovni. Budoucí: notifikace uživateli.

Příklad webhook payloadu — payment_intent.succeeded:

{
  "id": "evt_1PqXyz...",
  "object": "event",
  "type": "payment_intent.succeeded",
  "data": {
    "object": {
      "id": "pi_1PqXyz...",
      "object": "payment_intent",
      "amount": 5000,
      "currency": "usd",
      "status": "succeeded",
      "customer": "cus_1PqXyz...",
      "metadata": {
        "talkideUserId": "123",
        "topupId": "42"
      }
    }
  },
  "created": 1715000000
}

200 OK WebhookAckResponse:

{
  "received": true
}

Frontend

Validations

FieldConstraintsSizePatternNote
amountUsdrequired, positive integermin 5, max 500^\d+$Celá čísla v USD; min/max dle konfigurace BE. Zobrazit pod inputem: “Min $5 · Max $500”

UX Guidelines

Trigger: tlačítko “Add credit” v BillingSection.vue → sekce AI Usage.

Flow:

  1. Kliknutí otevře modal dialog s inputem “Amount (USD)” a tlačítkem “Pay now”.
  2. Input type="number", min="5", max="500", step="1". Placeholder: “50”.
  3. Zobrazit pod inputem helper text: “Min $5 · Max $500 · Charged to Visa •••• 4242”.
  4. “Pay now” je disabled pokud input prázdný nebo mimo rozsah.
  5. Na submit: loading = true, button disabled, spinner.
  6. Volání POST /billing/topup → FE obdrží clientSecret.
  7. stripe.confirmCardPayment(clientSecret) — Stripe SDK vede uživatele přes případný 3DS.
  8. Po úspěchu (Stripe vrátí succeeded): zavřít modal, zobrazit toast (green, 5 s): “Payment of $50 submitted. Credit will appear in a few seconds.”
  9. FE polluje GET /billing/topup/{topupId}/status každé 2 s (max 10 pokusů). Po SUCCEEDED refreshuje budget zobrazení.
  10. Na Stripe chybu (card_declined apod.): zobrazit Stripe error message inline pod tlačítkem (ne toast — chyba je user-actionable).
  11. Na BE chybu (422 NO_PAYMENT_METHOD): zobrazit toast (rose): “No payment method registered. Please add a card first.” + odkaz na sekci platební metody.

Stav po úspěchu:

AI Credit          $50.00 added   Balance: $73.45   [Add credit]

Backend

Configuration

application.yaml:

talkide:
  billing:
    topup:
      min-amount-usd: 5
      max-amount-usd: 500

Tyto hodnoty jsou čteny přes @ConfigurationProperties (třída TopupProperties) a injektovány do validační logiky. Změna nevyžaduje recompile.

DB Migration

Nový soubor migrace: 0027-create-credit-topup.xml

<changeSet id="0027-create-credit-topup" author="system">
    <createTable tableName="credit_topup">
        <column name="id" type="BIGINT" autoIncrement="true">
            <constraints primaryKey="true" nullable="false"/>
        </column>
        <column name="user_id" type="BIGINT">
            <constraints nullable="false" foreignKeyName="fk_credit_topup_user" references="users(id)"/>
        </column>
        <column name="amount_usd" type="NUMERIC(10,2)">
            <constraints nullable="false"/>
        </column>
        <column name="stripe_payment_intent_id" type="VARCHAR(255)">
            <constraints nullable="true" unique="true"/>
        </column>
        <column name="status" type="VARCHAR(20)">
            <constraints nullable="false"/>
        </column>
        <column name="created_at" type="TIMESTAMP WITH TIME ZONE" defaultValueComputed="NOW()">
            <constraints nullable="false"/>
        </column>
        <column name="completed_at" type="TIMESTAMP WITH TIME ZONE">
            <constraints nullable="true"/>
        </column>
    </createTable>
    <createIndex tableName="credit_topup" indexName="idx_credit_topup_user_id">
        <column name="user_id"/>
    </createIndex>
    <createIndex tableName="credit_topup" indexName="idx_credit_topup_stripe_pi_id" unique="true">
        <column name="stripe_payment_intent_id"/>
    </createIndex>
    <rollback>
        <dropTable tableName="credit_topup"/>
    </rollback>
</changeSet>

Sloupce:

  • id — surrogate primary key, auto-increment BIGINT.
  • user_id — FK na users.id, not null.
  • amount_usd — dobíjená částka v USD, NUMERIC(10,2), not null.
  • stripe_payment_intent_id — Stripe pi_... identifikátor; NULL mezi INSERT a Stripe response; UNIQUE (idempotence na DB úrovni).
  • statusVARCHAR(20), hodnoty: PENDING, SUCCEEDED, FAILED.
  • created_at — čas vytvoření záznamu (before Stripe call), DB default NOW().
  • completed_at — čas doručení payment_intent.succeeded / payment_failed webhookem; NULL dokud nezpracováno.

Validations

FieldConstraintsNote
JWT Authorization headernot_blank, valid signature, not expired401 pokud chybí nebo neplatné
amountUsd (POST /topup)not_null, integer, >= talkide.billing.topup.min-amount-usd, <= talkide.billing.topup.max-amount-usd400 VALIDATION_ERROR pokud mimo rozsah
Stripe Customer má default_payment_methodnot_null na Stripe Customer objektu422 NO_PAYMENT_METHOD pokud nenalezena
topupId (GET /topup/{id}/status)existuje v credit_topup, patří přihlášenému uživateli404 NOT_FOUND nebo 403 FORBIDDEN
Webhook Stripe-Signaturevalid HMAC-SHA256, timestamp within 5 min400 STRIPE_SIGNATURE_INVALID

Security

  • POST /api/v1/users/me/billing/topup — za JWT auth filtrem, přihlášený uživatel.
  • GET /api/v1/users/me/billing/topup/{topupId}/status — za JWT auth filtrem; BE ověří že credit_topup.user_id == authenticatedUserId (403 jinak).
  • POST /api/v1/stripe/webhookpermitAll(), autentizace přes Stripe-Signature (HMAC-SHA256). Sdílená konfigurace s UC-10006.
  • Stripe secret key (STRIPE_SECRET_KEY) a webhook signing secret (STRIPE_WEBHOOK_SECRET) jsou injektovány výhradně přes K8s Secret / env var — nikdy v kódu ani application.yaml.

Test Cases

GIVENWHENTHEN
Authenticated user, registered card (cus_… + default pm_…), amountUsd = 50POST /billing/topup je zavolán200 OK; credit_topup záznam vytvořen se statusem PENDING; PaymentIntent vytvořen na Stripe; clientSecret vrácen FE
Authenticated user, amountUsd = 4 (pod minimem)POST /billing/topup je zavolán400 VALIDATION_ERROR returned; žádný credit_topup záznam nevytvořen
Authenticated user, amountUsd = 501 (nad maximem)POST /billing/topup je zavolán400 VALIDATION_ERROR returned; žádný Stripe call neproveden
Authenticated user, amountUsd = 0POST /billing/topup je zavolán400 VALIDATION_ERROR returned
Authenticated user, amountUsd = -10POST /billing/topup je zavolán400 VALIDATION_ERROR returned
Authenticated user bez registrované karty (Stripe Customer nemá default_payment_method)POST /billing/topup je zavolán422 NO_PAYMENT_METHOD returned
Unauthenticated request (chybí Authorization header)POST /billing/topup je zavolán401 AUTHENTICATION_FAILED returned
Stripe API nedostupné při vytváření PaymentIntentPOST /billing/topup je zavolán502 STRIPE_UNAVAILABLE returned; pokud credit_topup byl insertován, zůstane ve stavu PENDING (cleanup TBD)
Valid Stripe-Signature, payment_intent.succeeded pro topupId=42, amountUsd=50webhook POST /stripe/webhook je zavolán200 OK; credit_topup status → SUCCEEDED; user_budget.ai_credit_usd += 50, ai_credit_initial_usd += 50; záznam v stripe_webhook_events
Stejný payment_intent.succeeded event doručen podruhé (Stripe retry)webhook POST /stripe/webhook je zavolán200 OK; idempotence check zastaví zpracování na stripe_webhook_events lookup; user_budget zůstane beze změny
payment_intent.succeeded pro topupId, který je již SUCCEEDED v credit_topupwebhook POST /stripe/webhook je zavolán200 OK; idempotence check na credit_topup.status zastaví zpracování; user_budget zůstane beze změny
Valid Stripe-Signature, payment_intent.payment_failed pro topupId=43webhook POST /stripe/webhook je zavolán200 OK; credit_topup status → FAILED; WARN log; user_budget beze změny
Invalid nebo chybějící Stripe-Signaturewebhook POST /stripe/webhook je zavolán400 STRIPE_SIGNATURE_INVALID returned
Authenticated user, topupId=42 (status PENDING)GET /billing/topup/42/status je zavolán200 OK; { status: "PENDING", completedAt: null } returned
Authenticated user, topupId=42 (status SUCCEEDED po webhooky)GET /billing/topup/42/status je zavolán200 OK; { status: "SUCCEEDED", completedAt: "..." } returned
topupId patří jinému uživateliGET /billing/topup/{id}/status je zavolán403 FORBIDDEN returned
topupId neexistujeGET /billing/topup/{id}/status je zavolán404 NOT_FOUND returned

Was this page helpful?

Thanks for the feedback.