Authenticated user registers a credit/debit card as their default payment method via Stripe SetupIntent flow. Requires a valid JWT access token.
- FE uses Stripe.js v3 + Stripe Elements (
CardElement) to collect card data. Raw card numbers never reach TalkIDE servers. - BE lazily creates a Stripe Customer for the user on the first billing action (if
stripe_customer_idis null inuserstable). The Customer ID is stored back tousers.stripe_customer_id. - Flow: BE creates
SetupIntent→ FE confirms withstripe.confirmCardSetup()→ Stripe attachesPaymentMethodto Customer → BE sets it asinvoice_settings.default_payment_methodon the Customer. - The FE polls / listens for the
setup_intent.succeededwebhook or — for simplicity in MVP — callsGET /api/v1/users/me/payment-methodafter confirmation to verify the card is attached. - Only one default payment method per user. Registering a new card replaces the previous default.
- Stripe test mode — use test card
4242 4242 4242 4242, any future expiry, any 3-digit CVC. - BE Foundation (be#111) prerequisite:
stripe_customer_id VARCHAR(255) NULLcolumn inuserstable (migration0021-add-stripe-customer-id-to-users.xml).
sequenceDiagram
actor User
User->>+FE: clicks "Change" in Billing → Payment method section
FE->>FE: mount Stripe Elements CardElement
FE->>-User: show card input form (Stripe Elements iframe)
User->>+FE: fills card details and clicks "Save card"
FE->>FE: validate that CardElement is complete (Stripe.js client-side)
alt CardElement incomplete
FE-->>User: show "Please complete your card details" error
end
FE->>+BE: POST /api/v1/users/me/billing/setup-intent <br> Authorization: Bearer {accessToken}
BE->>BE: validate JWT access token
alt access token invalid or missing
BE-->>FE: 401 Unauthorized <br> ErrorResponse
end
BE->>BE: resolve stripe_customer_id for user
alt stripe_customer_id is null (first billing action)
BE->>Stripe: stripe.customers.create({ email, metadata: { userId } })
Stripe-->>BE: Customer { id: "cus_..." }
BE->>DB: UPDATE users SET stripe_customer_id = "cus_..." WHERE id = userId
end
BE->>Stripe: stripe.setupIntents.create({ customer: "cus_...", payment_method_types: ["card"] })
Stripe-->>BE: SetupIntent { client_secret: "seti_...secret_..." }
BE->>-FE: 200 OK <br> SetupIntentResponse
FE->>Stripe: stripe.confirmCardSetup(clientSecret, { payment_method: { card: CardElement } })
Stripe-->>FE: { setupIntent: { status: "succeeded", payment_method: "pm_..." } }
alt Stripe confirmation error
Stripe-->>FE: { error: { message: "..." } }
FE-->>User: show Stripe error message (e.g. "Your card was declined.")
end
FE->>+BE: POST /api/v1/users/me/billing/payment-method <br> Authorization: Bearer {accessToken} <br> AttachPaymentMethodRequest
BE->>BE: validate JWT access token
alt access token invalid or missing
BE-->>FE: 401 Unauthorized <br> ErrorResponse
end
BE->>Stripe: stripe.customers.update(customerId, { invoice_settings: { default_payment_method: paymentMethodId } })
Stripe-->>BE: Customer (updated)
BE->>-FE: 200 OK <br> PaymentMethodResponse
FE->>-User: show updated card (brand + last4 + expiry), dismiss form
Step 1 — Create SetupIntent
POST /api/v1/users/me/billing/setup-intent (no request body; authentication via JWT Bearer token)
200 OK SetupIntentResponse:
{
"clientSecret": "seti_1PqXyz...secret_..."
}
401 Unauthorized ErrorResponse:
{
"status": 401,
"code": "AUTHENTICATION_FAILED",
"message": "Access token is missing or invalid"
}
502 Bad Gateway (Stripe API unreachable) ErrorResponse:
{
"status": 502,
"code": "STRIPE_UNAVAILABLE",
"message": "Payment provider is temporarily unavailable. Please try again."
}
Step 2 — Attach Payment Method as Default
POST /api/v1/users/me/billing/payment-method AttachPaymentMethodRequest:
{
"paymentMethodId": "pm_1PqXyz..."
}
200 OK PaymentMethodResponse:
{
"brand": "visa",
"last4": "4242",
"expMonth": 12,
"expYear": 2028,
"billingEmail": "jane@example.com"
}
400 Bad Request (validation) ErrorResponse:
{
"status": 400,
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"errors": [
{ "field": "paymentMethodId", "message": "must not be blank" }
]
}
401 Unauthorized ErrorResponse:
{
"status": 401,
"code": "AUTHENTICATION_FAILED",
"message": "Access token is missing or invalid"
}
422 Unprocessable Entity (PaymentMethod not attached to this Customer) ErrorResponse:
{
"status": 422,
"code": "STRIPE_PAYMENT_METHOD_INVALID",
"message": "The provided payment method could not be attached to your account."
}
502 Bad Gateway (Stripe API unreachable) ErrorResponse:
{
"status": 502,
"code": "STRIPE_UNAVAILABLE",
"message": "Payment provider is temporarily unavailable. Please try again."
}
Frontend
Validations
| Field / Control | Constraints | Note |
|---|---|---|
| CardElement | Stripe.js complete === true before submit | Validated client-side via Stripe.js; not sent to BE — raw card data never leaves the browser |
| Submit button | disabled while loading === true or CardElement incomplete | Prevents double-submit |
UX Guidelines
Trigger: “Change” button in BillingSection.vue → payment method row.
Flow:
- Clicking “Change” reveals inline card form with Stripe Elements
CardElement(iframe). - “Save card” button is disabled until
CardElement.on('change')emits{ complete: true }. - On submit:
loading = true, button disabled, spinner shown. - Step 1 call to
POST /setup-intent, thenstripe.confirmCardSetup(clientSecret, ...). - On Stripe confirmation error: display Stripe-provided
error.messageinline below CardElement. - On success: Step 2 call to
POST /payment-method. On 200: update UI card display, dismiss form. - On any BE error: show toast (rose, 5 s, dismissible) with error message.
Card display after registration:
Payment method Visa •••• 4242 Expires 12/28 [Change]
Brand displayed as icon + text; last4 + expiry from PaymentMethodResponse.
Backend
DB Migration
New migration file: 0021-add-stripe-customer-id-to-users.xml
<changeSet id="0021-add-stripe-customer-id-to-users" author="system">
<addColumn tableName="users">
<column name="stripe_customer_id" type="VARCHAR(255)">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
Stripe Customer Lifecycle
StripeCustomerService (new Spring @Service):
ensureCustomer(userId): String— returns existingstripe_customer_idor creates new Stripe Customer and persists to DB. Idempotent.- Called from all billing endpoints that require a Customer ID.
Validations
| Field | Constraints | Note |
|---|---|---|
JWT Authorization header | not_blank, valid signature, not expired | 401 if missing/invalid |
paymentMethodId (Step 2) | not_blank, matches pattern ^pm_[a-zA-Z0-9_]+$ | 400 VALIDATION_ERROR if invalid format |
Test Cases
| GIVEN | WHEN | THEN |
|---|---|---|
| Authenticated user with no existing stripe_customer_id | POST /setup-intent is called | New Stripe Customer created in test mode; stripe_customer_id persisted to users table; 200 OK with valid clientSecret returned |
| Authenticated user with existing stripe_customer_id | POST /setup-intent is called | Existing Customer reused; no duplicate Customer created; 200 OK with clientSecret returned |
| No Authorization header | POST /setup-intent is called | 401 AUTHENTICATION_FAILED returned |
| Stripe API returns error during Customer creation | POST /setup-intent is called | 502 STRIPE_UNAVAILABLE returned; no stripe_customer_id persisted |
Authenticated user, valid paymentMethodId “pm_card_visa” (test) | POST /payment-method is called | Stripe Customer updated with new default payment method; 200 OK with brand, last4, expiry returned |
paymentMethodId is blank | POST /payment-method is called | 400 VALIDATION_ERROR returned |
paymentMethodId does not belong to this Customer | POST /payment-method is called | 422 STRIPE_PAYMENT_METHOD_INVALID returned |
| No Authorization header | POST /payment-method is called | 401 AUTHENTICATION_FAILED returned |
| Stripe API unreachable during attach | POST /payment-method is called | 502 STRIPE_UNAVAILABLE returned |
| User registers second card (replacing first) | POST /payment-method is called with new pm_id | New card set as default; old payment method detached from Customer; 200 OK with new card details |
Thanks for the feedback.