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_ididefault_payment_methodjsou na Stripe Customer uloženy.stripe.paymentIntents.create()sconfirm: true+payment_method: defaultPmokamž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_confirmationnebo rovnousucceeded(záleží na 3DS). FE dostaneclientSecreta zavolástripe.confirmCardPayment(clientSecret)— Stripe SDK ošetří případnou 3DS autentizaci (challenge flow). Po úspěchu Stripe doručípayment_intent.succeededwebhook → BE idempotentně připíše kredit. - Metadata předání userId: při vytvoření PaymentIntent BE nastaví
metadata: { talkideUserId: userId }. Webhook handler mapujeevent.data.object.metadata.talkideUserId→ user. Zároveň ukládástripe_payment_intent_iddocredit_topupzá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 zkontrolujecredit_topup.stripe_payment_intent_idstatus — pokud jeSUCCEEDED, vrátí 200 bez úpravyuser_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-usdatalkide.billing.topup.max-amount-usdvapplication.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 karta4000 0025 0000 3155. - Záznam o dobití: tabulka
credit_topup(migrace0027) — stavPENDING → SUCCEEDED / FAILED. FE může pollovatGET /api/v1/billing/topup/{id}/statuspro 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í:
- Idempotence check přes
stripe_webhook_events.stripe_event_id(sdílená logika UC-10006 — vrátit 200 pokud duplicita). - Uložit do
stripe_webhook_events. - Načíst
metadata.topupIdametadata.talkideUserIdzevent.data.object.metadata. - Načíst
credit_topupřádek dletopupId— pokud status =SUCCEEDED, return 200 (idempotence na úrovni top-up záznamu). - V DB transakci:
UPDATE credit_topup SET status = SUCCEEDED, completed_at = NOW(), pakUPDATE user_budget SET ai_credit_usd += amountUsd, ai_credit_initial_usd += amountUsd. - Vrátit
200 OK { "received": true }.
payment_intent.payment_failed (volitelná větev):
- Idempotence check (stejný postup).
- Uložit do
stripe_webhook_events. UPDATE credit_topup SET status = FAILED WHERE id = topupId.- 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
| Field | Constraints | Size | Pattern | Note |
|---|---|---|---|---|
| amountUsd | required, positive integer | min 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:
- Kliknutí otevře modal dialog s inputem “Amount (USD)” a tlačítkem “Pay now”.
- Input
type="number",min="5",max="500",step="1". Placeholder: “50”. - Zobrazit pod inputem helper text: “Min $5 · Max $500 · Charged to Visa •••• 4242”.
- “Pay now” je disabled pokud input prázdný nebo mimo rozsah.
- Na submit:
loading = true, button disabled, spinner. - Volání
POST /billing/topup→ FE obdržíclientSecret. stripe.confirmCardPayment(clientSecret)— Stripe SDK vede uživatele přes případný 3DS.- 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.” - FE polluje
GET /billing/topup/{topupId}/statuskaždé 2 s (max 10 pokusů). PoSUCCEEDEDrefreshuje budget zobrazení. - Na Stripe chybu (card_declined apod.): zobrazit Stripe error message inline pod tlačítkem (ne toast — chyba je user-actionable).
- 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 nausers.id, not null.amount_usd— dobíjená částka v USD,NUMERIC(10,2), not null.stripe_payment_intent_id— Stripepi_...identifikátor;NULLmezi INSERT a Stripe response;UNIQUE(idempotence na DB úrovni).status—VARCHAR(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_failedwebhookem;NULLdokud nezpracováno.
Validations
| Field | Constraints | Note |
|---|---|---|
JWT Authorization header | not_blank, valid signature, not expired | 401 pokud chybí nebo neplatné |
amountUsd (POST /topup) | not_null, integer, >= talkide.billing.topup.min-amount-usd, <= talkide.billing.topup.max-amount-usd | 400 VALIDATION_ERROR pokud mimo rozsah |
| Stripe Customer má default_payment_method | not_null na Stripe Customer objektu | 422 NO_PAYMENT_METHOD pokud nenalezena |
topupId (GET /topup/{id}/status) | existuje v credit_topup, patří přihlášenému uživateli | 404 NOT_FOUND nebo 403 FORBIDDEN |
Webhook Stripe-Signature | valid HMAC-SHA256, timestamp within 5 min | 400 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ěří žecredit_topup.user_id == authenticatedUserId(403 jinak).POST /api/v1/stripe/webhook—permitAll(), 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 aniapplication.yaml.
Test Cases
| GIVEN | WHEN | THEN |
|---|---|---|
| Authenticated user, registered card (cus_… + default pm_…), amountUsd = 50 | POST /billing/topup je zavolán | 200 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án | 400 VALIDATION_ERROR returned; žádný credit_topup záznam nevytvořen |
| Authenticated user, amountUsd = 501 (nad maximem) | POST /billing/topup je zavolán | 400 VALIDATION_ERROR returned; žádný Stripe call neproveden |
| Authenticated user, amountUsd = 0 | POST /billing/topup je zavolán | 400 VALIDATION_ERROR returned |
| Authenticated user, amountUsd = -10 | POST /billing/topup je zavolán | 400 VALIDATION_ERROR returned |
| Authenticated user bez registrované karty (Stripe Customer nemá default_payment_method) | POST /billing/topup je zavolán | 422 NO_PAYMENT_METHOD returned |
| Unauthenticated request (chybí Authorization header) | POST /billing/topup je zavolán | 401 AUTHENTICATION_FAILED returned |
| Stripe API nedostupné při vytváření PaymentIntent | POST /billing/topup je zavolán | 502 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=50 | webhook POST /stripe/webhook je zavolán | 200 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án | 200 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_topup | webhook POST /stripe/webhook je zavolán | 200 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=43 | webhook POST /stripe/webhook je zavolán | 200 OK; credit_topup status → FAILED; WARN log; user_budget beze změny |
Invalid nebo chybějící Stripe-Signature | webhook POST /stripe/webhook je zavolán | 400 STRIPE_SIGNATURE_INVALID returned |
| Authenticated user, topupId=42 (status PENDING) | GET /billing/topup/42/status je zavolán | 200 OK; { status: "PENDING", completedAt: null } returned |
| Authenticated user, topupId=42 (status SUCCEEDED po webhooky) | GET /billing/topup/42/status je zavolán | 200 OK; { status: "SUCCEEDED", completedAt: "..." } returned |
| topupId patří jinému uživateli | GET /billing/topup/{id}/status je zavolán | 403 FORBIDDEN returned |
| topupId neexistuje | GET /billing/topup/{id}/status je zavolán | 404 NOT_FOUND returned |
Thanks for the feedback.