Technical Integration with Stripe
This document provides detailed technical information about the Stripe integration, including API usage, data structures, and code patterns.
Stripe API Version & Configuration
The application uses the Stripe Ruby SDK with the following configuration:
API Keys
# Configuration in Rails secrets
Stripe.api_key = Rails.application.secrets.stripe_secret_key!
publishable_key = Rails.application.secrets.stripe_publishable_key!
signing_secret = Rails.application.secrets.stripe_signing_secret! # For webhooks
Idempotency
All Stripe API calls that create resources use idempotency keys to prevent duplicate charges:
Stripe::PaymentIntent.create(payment_data, idempotency_key: "payment-#{uuid}")
Stripe::Subscription.create(subscription_params, idempotency_key: "subscription-#{uuid}")
Stripe Objects Used
1. Customer (Stripe::Customer)
Each donor in the database corresponds to a Stripe Customer.
Creation:
# app/models/donations/donor.rb
def stripe_customer
if stripe?
Stripe::Customer.retrieve(stripe_customer_id)
else
metadata = {
'first_name' => first_name,
'last_name' => last_name,
'donor_id' => id
}
Stripe::Customer.create(
email: email,
metadata: metadata,
preferred_locales: [locale],
idempotency_key: idempotency_key
)
end
end
Metadata Stored:
first_name: Donor's first namelast_name: Donor's last namedonor_id: Application donor IDlocale: Preferred language for communications
2. PaymentIntent (Stripe::PaymentIntent)
Used for one-time card and SEPA payments.
Creation API Call:
# app/models/donations/request.rb
def payment_intent!
payment_data = {
payment_method_types: ['card', 'sepa_debit'], # Or just ['card']
customer: customer.id,
amount: amount_cents,
currency: amount_currency,
description: I18n.t('donations.contribution_description', locale: locale),
metadata: request_metadata
}
Stripe::PaymentIntent.create(payment_data)
end
Metadata Structure:
{
locale: 'it', # User's language
campaign: 'Spring2018', # Campaign name (optional)
campaign_id: 123, # Campaign database ID (optional)
recaptcha_score: 0.9, # reCAPTCHA score (if enabled)
recaptcha_action: 'checkout', # reCAPTCHA action
user_ip: '192.168.1.1', # IP address
user_city: 'Rome', # Geocoded city
user_country: 'IT', # Geocoded country code
user_location: '41.9028,12.4964' # Lat/long coordinates
}
Payment Method Types:
card: Credit/debit cards (always enabled)sepa_debit: SEPA Direct Debit (EUR only, when SEPA feature enabled)- Note:
p24is handled via Checkout Sessions, not PaymentIntents
3. SetupIntent (Stripe::SetupIntent)
Used for recurring donations to authorize future charges without immediate payment.
Creation:
# app/models/donations/request.rb
def payment_intent! # Method name is reused
payment_data = {
payment_method_types: ['card', 'sepa_debit'],
customer: customer.id,
description: I18n.t('donations.contribution_description'),
metadata: request_metadata
# No amount or currency for SetupIntent
}
Stripe::SetupIntent.create(payment_data)
end
Flow:
- SetupIntent created with payment method types
- Frontend confirms payment method
- Payment method attached to customer
- Application creates subscription with payment method
- Stripe immediately charges first payment
4. Plan (Stripe::Plan)
Defines the amount and frequency for recurring subscriptions.
Dynamic Plan Creation:
# app/models/donations/request.rb
def plan
plan_id = "#{amount_cents}-#{amount_currency}-#{period}"
Stripe::Plan.retrieve(plan_id)
rescue Stripe::InvalidRequestError
# Plan doesn't exist, create it
Stripe::Plan.create(
{
id: plan_id,
product: { name: "Recurring subscription: #{amount.format} #{period}ly" },
amount: amount_cents,
currency: amount_currency,
interval: period # 'month', 'year', 'day'
},
idempotency_key: "plan-#{uuid}"
)
end
Plan Naming Convention:
- Format:
{amount_cents}-{currency}-{interval} - Examples:
3000-EUR-month= €30 per month10000-USD-month= $100 per month5000-EUR-year= €50 per year
5. Subscription (Stripe::Subscription)
Represents a recurring donation.
Creation:
# app/models/donations/request.rb
def subscription!(payment_method_id)
subscription_params = {
customer: customer.id,
items: [{ plan: plan.id }],
default_payment_method: payment_method_id,
metadata: request_metadata
}
Stripe::Subscription.create(
subscription_params,
idempotency_key: "subscription-#{uuid}"
)
end
Subscription Behavior:
- First charge happens immediately upon creation
- Subsequent charges on monthly anniversary
- Each charge creates an invoice
- Failed payments trigger retry logic (Stripe's default behavior)
6. Checkout Session (Stripe::Checkout::Session)
Used for hosted checkout page (legacy flow and P24).
Creation:
# app/models/donations/request.rb
def checkout_session!(success_url:, failure_url:, image: nil)
session_data = {
payment_method_types: ['card', 'p24'], # P24 only via Checkout
success_url: add_query_string_info(success_url, result: 'success'),
cancel_url: add_query_string_info(failure_url, result: 'fail'),
locale: stripe_locale,
customer: donor.stripe_customer_id,
metadata: request_metadata
}
if recurring?
session_data[:subscription_data] = {
items: [{ plan: plan.id }],
metadata: request_metadata
}
else
session_data[:line_items] = [{
amount: amount_cents,
currency: amount_currency,
name: I18n.t('donations.contribution_description'),
quantity: 1
}]
session_data[:submit_type] = :donate
session_data[:payment_intent_data] = { metadata: request_metadata }
end
Stripe::Checkout::Session.create(session_data)
end
P24 Requirements:
- Must use Checkout Session (not PaymentIntent directly)
- Payment method type:
'p24' - After payment, source status becomes "consumed"
- Cannot reuse P24 sources (must check status)
7. Source (Stripe::Source)
Legacy object for P24 and some other payment methods.
Handling P24 Sources:
# app/models/donations/request.rb
def create_customer_source(stripe_token)
if stripe_token.start_with?('src_') &&
(source = Stripe::Source.retrieve(stripe_token))[:customer].present?
source
else
customer.sources.create(
{ source: stripe_token },
idempotency_key: "source-#{uuid}"
)
end
end
def already_consumed?(source)
if source.is_a?(Stripe::Source) && source.status == 'consumed'
logger.warn "Source #{source.id} already consumed, skipping"
true
end
end
API Endpoints
POST /donations/payment_intent
Creates a PaymentIntent or SetupIntent for the donation.
Request Parameters:
{
"payment_request": {
"email": "[email protected]",
"amount_cents": 3000,
"amount_currency": "EUR",
"period": "single",
"campaign": "Spring2018",
"locale": "it"
}
}
Response (Success):
{
"id": "pi_1234567890ABCDEF",
"object": "payment_intent",
"amount": 3000,
"currency": "eur",
"customer": "cus_ABCDEF123456",
"client_secret": "pi_1234567890ABCDEF_secret_xyz",
"status": "requires_payment_method"
}
Response (Error):
{
"email": ["can't be blank"],
"amount_cents": ["must be greater than 0"]
}
HTTP Status: 422 Unprocessable Entity
POST /donations/create_subscription
Creates a Stripe subscription after payment method confirmation.
Request Parameters:
{
"payment_request": {
"email": "[email protected]",
"amount_cents": 3000,
"amount_currency": "EUR",
"period": "month",
"campaign": "Spring2018",
"locale": "it"
},
"payment_method": "pm_1234567890ABCDEF"
}
Response (Success):
{
"id": "sub_1234567890ABCDEF",
"object": "subscription",
"customer": "cus_ABCDEF123456",
"plan": {
"id": "3000-EUR-month",
"amount": 3000,
"currency": "eur",
"interval": "month"
},
"status": "active",
"current_period_end": 1640995200
}
POST /donations/checkout
Creates a Stripe Checkout Session (legacy and P24).
Request Parameters:
{
"session_request": {
"email": "[email protected]",
"amount_cents": 3000,
"amount_currency": "EUR",
"period": "single",
"campaign": "Spring2018",
"locale": "it"
}
}
Response (Success):
{
"id": "cs_test_a1b2c3d4e5f6",
"object": "checkout.session",
"url": "https://checkout.stripe.com/pay/cs_test_..."
}
Frontend redirects user to the url.
GET /donations/thankyou
Success/failure page after payment.
Query Parameters:
status: "failed" or omitted (success)locale: Language code
Response: Renders HTML panel (success or failure message)
GET /donations/campaign_data
Retrieves campaign information by name.
Query Parameters:
name: Campaign name (e.g., "Spring2018")
Response:
{
"id": 1,
"name": "Spring2018",
"target_cents": 10000000,
"target_currency": "EUR",
"created_at": "2018-03-01T00:00:00.000Z"
}
GET /donations/available_currencies
Returns list of enabled currencies.
Response:
["EUR", "USD", "PLN"]
Currencies from ALLOWED_CURRENCIES + enabled OPTIONAL_CURRENCIES.
GET /donations/currency_info
Returns currency and location info based on user's IP address.
Response:
{
"ip": "192.168.1.1",
"city": "Rome",
"region": "Lazio",
"country": "IT",
"loc": "41.9028,12.4964",
"currency": "EUR"
}
Uses IpInfoService to geocode the request IP.
Request Validation
The Donations::Request model validates all donation parameters:
Validations Applied
validates :email, presence: true, email_format: true
validates :ip_address, format: { with: Resolv::IPv4::Regex, allow_blank: true }
validates :amount_cents, presence: true, numericality: { greater_than: 0 }
validates :amount_currency, presence: true, inclusion: { in: allowed_currencies }
validates :period, presence: true, inclusion: { in: ['single', 'day', 'month', 'year'] }
validates :locale, inclusion: { in: I18n.available_locales }
# Custom validation
validate :minimum_amount # Must be ≥ €1.00 equivalent
Minimum Amount Logic
def minimum_amount
errors.add(:amount_cents, 'value must be at least €1.00') unless converted_amount_cents >= 100
end
def converted_amount_cents
amount.exchange_to(:eur).cents
end
All amounts converted to EUR for validation regardless of selected currency.
Error Handling
Stripe API Errors
CardError (Payment Failed):
# app/jobs/donations/process_stripe_token_job.rb
rescue Stripe::CardError => e
Rollbar.error('Failed stripe request', e, data: {
payload: request.to_h.except(:stripe_token),
code: e.code,
body: e.json_body
})
# Send staff notification
Donations::DonorMailer.staff_charge_failed_notification(
request.to_h,
e.code,
e.json_body
)
end
Common Error Codes:
card_declined: General declineinsufficient_funds: Not enough balancelost_card: Card reported loststolen_card: Card reported stolenexpired_card: Card is expiredincorrect_cvc: Wrong security codeprocessing_error: Stripe processing issuerate_limit: Too many API requests
SignatureVerificationError (Webhook):
# app/controllers/donations/stripe_controller.rb
def event
event = Stripe::Webhook.construct_event(
request.body.read,
request.headers['Stripe-Signature'],
STRIPE_SIGNING_SECRET_V2
)
Donations::ProcessStripeEventJob.perform_later(event.id)
head :ok
rescue Stripe::SignatureVerificationError => e
logger.warn "Failed to verify stripe webhook: #{e.message}"
Rollbar.warning('Failed to verify stripe webhook', e)
head :bad_request
end
Application-Level Errors
ValidationError:
# app/models/donations/request.rb
ValidationError = Class.new(StandardError)
def self.validate!(request_params)
new(request_params).tap do |request|
raise ValidationError, request.errors.full_messages.to_sentence unless request.valid?
end
end
NeedInvestigationError:
# app/jobs/donations/process_stripe_event_job.rb
NeedInvestigationError = Class.new(StandardError)
def process_charge
charge = event.data.object
raise NeedInvestigationError, "Anomaly in event #{event.id}" unless charge['status'] == 'succeeded'
# ... process charge
end
This error indicates data inconsistencies requiring manual review.
Currency Handling
Supported Currencies
# Always available
ALLOWED_CURRENCIES = %w[EUR USD]
# Available via feature flags
OPTIONAL_CURRENCIES = %w[BRL PLN MXN COP ARS]
def self.allowed_currencies
all_currencies.subtract(disabled_currencies).to_a
end
def self.disabled_currencies
OPTIONAL_CURRENCIES.reject do |c|
Flipper.enabled?(FlippableFeatures::CURRENCY_ENABLED, Money::Currency.new(c))
end
end
Currency Conversion
# All transactions converted to EUR for reporting
def converted_amount_cents
amount.exchange_to(:eur).cents
end
# Uses Money gem with bank exchange rates
Money.default_bank # Configured with exchange rates
Locale to Stripe Locale Mapping
STRIPE_LOCALES = {
ar: nil, # Arabic -> Stripe default
es: :es, # Spanish
en: :en, # English
fr: :fr, # French
it: :it, # Italian
pt: :pt, # Portuguese
pl: :pl, # Polish
si: nil, # Slovenian -> Stripe default
}
def stripe_locale
STRIPE_LOCALES.fetch(locale.to_sym, I18n.default_locale)
end
Payment Intent Expiration
To prevent abandoned payment intents from being reused:
Automatic Expiration
# app/jobs/donations/process_stripe_event_job.rb
when 'payment_intent.created'
Donations::ExpirePaymentIntentJob.set(wait: 30.minutes)
.perform_later(event.data.object.id)
Expiration Job
# app/jobs/donations/expire_payment_intent_job.rb
CANCELABLE_STATUSES = %w[
requires_payment_method
requires_capture
requires_confirmation
requires_action
].to_set.freeze
def perform(intent_id, reason: 'abandoned')
intent = Stripe::PaymentIntent.retrieve(id: intent_id, expand: ['invoice'])
if cancelable?(intent)
if intent.invoice
# Recurring payment - void invoice
intent.invoice.void_invoice
else
# One-time payment - cancel intent
intent.cancel(cancellation_reason: reason)
end
end
end
Cancellation Reasons:
abandoned: User left without completing (30 min timeout)fraudulent: Fraud detected (multiple failures)
Security Features
Idempotency Keys
Prevent duplicate charges from network retries:
# Format: "{operation}-{uuid}"
idempotency_key: "payment-#{SecureRandom.uuid}"
idempotency_key: "subscription-#{SecureRandom.uuid}"
idempotency_key: "charge-#{uuid}" # uuid from request
Rate Limiting
Track failed payment attempts to detect fraud:
# After each charge.failed event
Stripe.limits.add("charge.failed::#{charge.payment_intent}")
# Check if threshold exceeded
def too_much_failures?(charge)
Stripe.limits.exceeded?(
charge_failure_id(charge),
threshold: 3,
interval: 30.minutes
)
end
reCAPTCHA Integration
# app/controllers/donations/stripe_controller.rb
before_action :validate_recaptcha, only: [:checkout, :payment_intent, :create_subscription]
def validate_recaptcha
return unless Flipper.enabled?(FlippableFeatures::RECAPTCHA)
verify_recaptcha!(
response: params[:captcha_response],
action: action_name,
minimum_score: Setting.recaptcha_minimum_score,
secret_key: Rails.application.secrets.recaptcha_v3_secret_key!
)
rescue Recaptcha::VerifyError
render json: { error: ['reCAPTCHA verification failed'] }, status: :forbidden
end
Score and action stored in metadata for analysis.
Frontend Integration
Stripe.js Initialization
// Frontend loads Stripe with publishable key
const stripe = Stripe(stripePublishableKey);
// Create Elements instance
const elements = stripe.elements();
// Mount card element
const cardElement = elements.create('card');
cardElement.mount('#card-element');
Payment Intent Confirmation
// Create payment intent
const response = await fetch('/donations/payment_intent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ payment_request: donationData })
});
const { client_secret } = await response.json();
// Confirm payment
const { error, paymentIntent } = await stripe.confirmCardPayment(
client_secret,
{
payment_method: {
card: cardElement,
billing_details: { email: donorEmail }
}
}
);
if (error) {
// Show error message
} else if (paymentIntent.status === 'succeeded') {
// Show success message
}
Setup Intent for Recurring
const { client_secret } = await createSetupIntent();
const { error, setupIntent } = await stripe.confirmCardSetup(
client_secret,
{
payment_method: {
card: cardElement,
billing_details: { email: donorEmail }
}
}
);
if (!error) {
// Create subscription with payment method
await fetch('/donations/create_subscription', {
method: 'POST',
body: JSON.stringify({
payment_request: donationData,
payment_method: setupIntent.payment_method
})
});
}
Next Steps
- Webhooks - Event processing and handling
- Error Handling - Detailed error scenarios
- Admin Features - Reporting and management