Skip to main content

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 name
  • last_name: Donor's last name
  • donor_id: Application donor ID
  • locale: 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: p24 is 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:

  1. SetupIntent created with payment method types
  2. Frontend confirms payment method
  3. Payment method attached to customer
  4. Application creates subscription with payment method
  5. 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 month
    • 10000-USD-month = $100 per month
    • 5000-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": "donor@example.com",
"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": "donor@example.com",
"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": "donor@example.com",
"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 decline
  • insufficient_funds: Not enough balance
  • lost_card: Card reported lost
  • stolen_card: Card reported stolen
  • expired_card: Card is expired
  • incorrect_cvc: Wrong security code
  • processing_error: Stripe processing issue
  • rate_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