```
This approach is ideal when:
- You need a one-off style tweak for a specific page
- You want to keep the component untouched for upstream updates
- The customization is purely visual (fonts, colors, spacing)
---
## AI image generation
---
title: AI image generation
sort: 2
---
---
## LLMs and AI chats
---
title: LLMs and AI chats
sort: 3
---
---
## AI, vector databases, and RAG
RailsFast includes built-in support for AI-powered features, vector search, and Retrieval-Augmented Generation (RAG) to help you build intelligent applications.
## Overview
Modern Rails applications can leverage AI for:
- Semantic search and similarity matching
- Content recommendations
- Document question-answering
- Intelligent data retrieval
- Contextual assistance
## Vector Databases
Vector databases store embeddings (numerical representations of text) that enable semantic search.
### Supported Solutions
RailsFast works with:
- **pgvector** - PostgreSQL extension (recommended for most projects)
- **Pinecone** - Managed vector database
- **Weaviate** - Open-source vector search engine
- **Milvus** - Scalable vector database
### Using pgvector
pgvector is included in your PostgreSQL setup.
#### Enable the Extension
```ruby
# db/migrate/xxx_enable_pgvector.rb
class EnablePgvector < ActiveRecord::Migration[8.0]
def change
enable_extension 'vector'
end
end
```
#### Create a Model with Embeddings
```ruby
# db/migrate/xxx_add_embeddings_to_documents.rb
class AddEmbeddingsToDocuments < ActiveRecord::Migration[8.0]
def change
add_column :documents, :embedding, :vector, limit: 1536
add_index :documents, :embedding, using: :ivfflat, opclass: :vector_cosine_ops
end
end
```
```ruby
# app/models/document.rb
class Document < ApplicationRecord
has_neighbors :embedding
end
```
## Generating Embeddings
### Using OpenAI
Install the OpenAI gem:
```ruby
# Gemfile
gem 'ruby-openai'
```
Generate embeddings:
```ruby
# app/services/embedding_service.rb
class EmbeddingService
def self.generate(text)
client = OpenAI::Client.new(
access_token: Rails.application.credentials.dig(:openai, :api_key)
)
response = client.embeddings(
parameters: {
model: "text-embedding-3-small",
input: text
}
)
response.dig("data", 0, "embedding")
end
end
```
Use it in your model:
```ruby
class Document < ApplicationRecord
has_neighbors :embedding
after_save :generate_embedding, if: -> { content_changed? }
private
def generate_embedding
update_column :embedding, EmbeddingService.generate(content)
end
end
```
## Semantic Search
### Finding Similar Documents
```ruby
# Find documents similar to a query
query = "How do I set up payments?"
query_embedding = EmbeddingService.generate(query)
similar_docs = Document.nearest_neighbors(
:embedding,
query_embedding,
distance: "cosine"
).limit(5)
```
### Building a Search Controller
```ruby
# app/controllers/search_controller.rb
class SearchController < ApplicationController
def show
@query = params[:q]
return if @query.blank?
query_embedding = EmbeddingService.generate(@query)
@results = Document.nearest_neighbors(
:embedding,
query_embedding,
distance: "cosine"
).limit(10)
end
end
```
## RAG (Retrieval-Augmented Generation)
RAG combines vector search with LLMs to provide contextual answers.
### Basic RAG Implementation
```ruby
# app/services/rag_service.rb
class RagService
def self.answer_question(question)
# 1. Find relevant documents
query_embedding = EmbeddingService.generate(question)
relevant_docs = Document.nearest_neighbors(
:embedding,
query_embedding,
distance: "cosine"
).limit(3)
# 2. Build context from documents
context = relevant_docs.map(&:content).join("\n\n")
# 3. Generate answer with LLM
client = OpenAI::Client.new(
access_token: Rails.application.credentials.dig(:openai, :api_key)
)
response = client.chat(
parameters: {
model: "gpt-4-turbo-preview",
messages: [
{
role: "system",
content: "Answer questions based on the provided context."
},
{
role: "user",
content: "Context:\n#{context}\n\nQuestion: #{question}"
}
]
}
)
response.dig("choices", 0, "message", "content")
end
end
```
### RAG Controller
```ruby
# app/controllers/ai_assistant_controller.rb
class AiAssistantController < ApplicationController
def ask
@question = params[:question]
@answer = RagService.answer_question(@question)
respond_to do |format|
format.json { render json: { answer: @answer } }
format.html
end
end
end
```
## Advanced Patterns
### Hybrid Search
Combine traditional search with vector search:
```ruby
class Document < ApplicationRecord
has_neighbors :embedding
def self.hybrid_search(query, limit: 10)
# Vector search
query_embedding = EmbeddingService.generate(query)
vector_results = nearest_neighbors(
:embedding,
query_embedding,
distance: "cosine"
).limit(limit * 2)
# Keyword search
keyword_results = where("content ILIKE ?", "%#{query}%")
.limit(limit * 2)
# Combine and deduplicate
(vector_results + keyword_results).uniq.take(limit)
end
end
```
### Caching Embeddings
Cache embeddings to reduce API costs:
```ruby
class EmbeddingService
def self.generate(text)
cache_key = "embedding:#{Digest::SHA256.hexdigest(text)}"
Rails.cache.fetch(cache_key, expires_in: 30.days) do
# API call to generate embedding
client = OpenAI::Client.new(...)
# ... generate and return embedding
end
end
end
```
### Batch Processing
Generate embeddings in background jobs:
```ruby
class GenerateEmbeddingsJob < ApplicationJob
queue_as :default
def perform(document_ids)
documents = Document.where(id: document_ids, embedding: nil)
documents.each do |doc|
doc.update(embedding: EmbeddingService.generate(doc.content))
end
end
end
# Usage
GenerateEmbeddingsJob.perform_later(Document.pluck(:id))
```
## Configuration
Add your AI service credentials:
```bash
EDITOR="cursor --wait" bin/rails credentials:edit
```
```yaml
openai:
api_key: sk-...
# Or for other services
anthropic:
api_key: sk-ant-...
pinecone:
api_key: ...
environment: us-east-1-aws
```
## Performance Tips
1. **Index your embeddings**: Use appropriate index types (IVFFlat, HNSW)
2. **Batch embed operations**: Generate embeddings in batches for better throughput
3. **Cache frequently used embeddings**: Reduce API costs
4. **Use smaller models when possible**: `text-embedding-3-small` is faster and cheaper
5. **Implement retry logic**: Handle API failures gracefully
## Cost Optimization
- Cache embeddings aggressively
- Use background jobs for non-urgent embedding generation
- Choose appropriate embedding models (smaller = cheaper)
- Implement rate limiting on user queries
- Monitor usage and set budgets
## Example Use Cases
### Documentation Search
```ruby
# Find relevant docs for a user question
question = "How do I configure email?"
answer = RagService.answer_question(question)
```
### Content Recommendations
```ruby
# Find similar articles
article = Article.find(params[:id])
similar = Article.nearest_neighbors(:embedding, article.embedding).limit(5)
```
### Intelligent Support Bot
```ruby
# Answer customer questions
customer_query = "Do you offer refunds?"
support_answer = RagService.answer_question(customer_query)
```
## Resources
- [pgvector GitHub](https://github.com/pgvector/pgvector)
- [Neighbor gem (Rails integration)](https://github.com/ankane/neighbor)
- [OpenAI Embeddings Guide](https://platform.openai.com/docs/guides/embeddings)
- [RAG Explained](https://www.anthropic.com/research/rag)
## See Also
- [[background-jobs|Background Jobs]] - For async embedding generation
- [[configuration|Configuration]] - Setting up AI credentials
- [[development|Development]] - Testing AI features locally
---
## How to get a 10/10 deliverability score for your emails
RailsFast can achieve a 10/10 email deliverability out of the box, as tested by mail-tester.com
To achieve that, when configuring AWS SES for sending email, make sure to strictly follow these steps:
- Navigate to your Amazon SES dashboard
- Add your project domain name as a SES identity
- To do that, on the sidebar, navigate to Configuration > Identities and click [ Create Identity ]
- In the new page, select "Domain" as the new identity type
- Enter your domain
- Activate the "Use a custom MAIL FROM domain" checkbox
- In the MAIL FROM field, input something like `mail.
`
- Scroll down and select "Easy DKIM"
- Select RSA_2048_BIT
- Leave "DKIM Signatures" active
- Click on the [ Crete identity ] button
- On the new identiy page, you'll see three main sections: DKIM, MAIL FROM, and DMARC. For each section, there's a "Publish DNS records" unfoldable item. If you click it, you'll see the DNS records you need to add to your domain's DNS to clear each section. It's a somewhat tedious job, but make sure you add all records right.
- Once you're done adding all records, scroll up in the indentity page in SES and click the refresh button. If everything went right, you should see your "Identity status: Verified", and the DKIM and MAIL FROM sections marked as successful. If that did not happen, give your DNS time to propagate (anywhere from some minutes to a few hours) and try again.
- RailsFast is configured with best email practices so that when you send a transactional email from Rails you should get a perfect 10/10 score (or pretty close to it) in deliverability: you can test it at mail-tester.com
---
## Kamal - Configure a container registry
Kamal uses your local development machine as a Docker container registry by default.
This means that, after the Docker image containing your app is built, it's stored in your local computer and needs to be uploaded from your computer to the production server for deployment.
If your internet connection is not the best, this may slow down your total deployment time from the time you hit `kamal deploy` until the time everything is live in production. Also, you may be filling up your local machine with big stale Docker images.
> [!TIP]
> Using a remote container registry usually speeds up deployment times.
There are many remote container registry options: Docker Hub, ghcr.io, GitLab container registry, etc.
Some are paid, some are free (but always public, which is not supported on RailsFast).
What I found to be easiest to configure and integrate with Kamal (RailsFast) is AWS ECR.
## How to use AWS ECR with Kamal as container registry
AWS ECR costs close to nothing (I paid $0.32 –yes, 32 cents– last month), and it's very straightforward to integrate with Kamal using the AWS CLI. And since we're already using AWS for storage (S3) and emailing (SES) in RailsFast, it makes sense to also leverage their container registry.
### Install and configure the AWS CLI
First, [install and configure the AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html).
Then, at the end of the Kamal secrets file under `.kamal/secrets`, uncomment this line:
```
AWS_ECR_PASSWORD=$(aws ecr get-login-password --region us-east-1 --profile default)
```
Make sure to change the `us-east-1` for the region you're using in ECR, or leave it like that if you're fine with us-east-1.
> [!INFO]
> This is a secure way of sharing your AWS ECR credentials with Kamal for deployment. No password, API token or anything sensible ever gets commited or made public. Upon every deployment, Kamal will call the AWS CLI on your local computer to get a temporary login password for ECR, and will load that token as the `AWS_ECR_PASSWORD` ENV var in the containers that need it.
### Create a private registry on AWS ECR
Go to your AWS console and navigate to [ECR (Elastic Container Registry)](https://us-east-1.console.aws.amazon.com/ecr/private-registry/repositories?region=us-east-1).
Click on the ` [ Create repository ] ` button.
Give your registry a name with a username in front. Example: use `myusername/myproject`, not just `myproject`. This is because Docker container registries require username namespacing (a container must belong to a username). Tip: just use the same username you use on GitHub and the same project name you use on GitHub.
Keep the rest of the settings default and click on `[ Create ]`
Since we're at it, let's do a couple more clicks to limit how many versions of our Docker images the registry is going to store (saving us storage money!)
Select your recently created repository from the list of private repositories. On top, click `[Actions ▼]` and then `Lifecycle policies`. Click on `[Create rule]` to create a new rule, and in the new screen leave everything by default but where it says: `Image tag status` (select: `Any`). Then, under `Match criteria`, I suggest you select `Days since image created` or `Image count`. In the box below, you can specify for how many days you want to store each version, or how many version (images) in total you want to storage.
I personally just select `Image count`, and set it to only save the last `30` images. Click `[Save]` and `[Confirm]` and you won't be storing old, stale versions forever.
### Edit `deploy.yml`
Now that we have everything configured in ECR, in your `config/deploy.yml` let Kamal know which container registry you're using:
- Change your `image` name to match the name you gave it in AWS ECR. Example, if before you had something like:
```yaml
image: myproject
```
Edit it to make it match what you had on ECR:
```yaml
image: myusername/myproject
```
- Remove the `localhost:5555` `server` under the `registry` section and uncomment the placeholder remote container registry options (`server`, `username`, `password`), to end up with something like this:
```yaml
registry:
server: 1234.dkr.ecr.us-east-1.amazonaws.com
username: AWS
password:
- AWS_ECR_PASSWORD
```
Replace the placeholder `1234.dkr.ecr.us-east-1.amazonaws.com` with the actual repository URI you can see on ECR in your private repositories list.
> [!IMPORTANT]
> If you're using a remote builder, make sure to pass the `AWS_ECR_PASSWORD` ENV var to the builder too, under the `secrets` section of the `builder` key:
>
> ```
> builder:
> secrets:
> - RAILS_MASTER_KEY
> - AWS_ECR_PASSWORD
>```
And you're done!
You can check the remote Docker container registry in AWS ECR is set up correctly in Kamal by running:
```bash
kamal registry setup
```
And seeing that it logs in correctly.
The next time you deploy using `kamal deploy`, Kamal will push and pull the Docker images from ECR instead of from your local computer registry.
---
You can learn more about Kamal and container registries in the [official Kamal docs](https://kamal-deploy.org/docs/configuration/docker-registry/)
---
## Kamal - Configure a remote builder
> [!IMPORTANT]
> If you're on macOS, especially M1, M2, M3... (Apple Silicon), you may **have to** use a remote builder or your build process may fail.
A remote builder is just a server that builds your app for you, instead of doing the build work in your local machine. You send your code to the remote builder, and it produces the deployeable Docker image.
This is useful, for example, in case your local development machine is too slow building, and you want to speed things up.
It may also me something you **need to** to if you're on macOS.
The newest macOS machines (the Apple Silicon family: M1, M2, M3, etc.) may find issues with Docker (the underlying technology Kamal uses for building and deploying your app); **this is fully solvable** if you use a remote builder instead of your local Mac for building.
## How to set up a remote builder server in Kamal
Setting up a remote builder couldn't be easier: just spin up a new server on Hetzner exactly as you did on the [[getting-started/2-quickstart|Quickstart]]. In a nutshell: select a server and a location, add your SSH key, __no need__ for backups this time, and in the **Cloud Config** box, paste this:
```yaml
#cloud-config
runcmd:
- bash -lc 'set -euo pipefail; wget -qO /root/railsfast-setup.sh https://setup.railsfast.com; chmod +x /root/railsfast-setup.sh; /root/railsfast-setup.sh 2>&1 | tee -a /var/log/railsfast-setup.log && reboot'
```
Create the server and wait for the setup script to finish as described in the [[getting-started/2-quickstart|Quickstart]].
Then, in your `deploy.yml` file, uncomment the remote builder machine section under the `builder` key:
```yaml
builder:
arch: amd64
# Uncomment this:
local: false
remote: ssh://docker@5.6.7.8
args:
RUBY_VERSION: 3.4.7
secrets:
- RAILS_MASTER_KEY
```
And change the placeholder `5.6.7.8` IP for the actual IP of the server you've just created.
That's it! Try deploying with `kamal deploy` now and your setup will use the remote builder for building, instead of your local machine.
---
## Migrating Existing Project
Here's the simplest, most reliable way to "adopt" RailsFast into an existing Rails project and start getting updates.
### Goal
- Link your project’s history to the RailsFast template so future updates are just normal git merges.
- Keep your current code as-is during adoption.
- Preserve secrets/config with merge guards.
### One-time adoption (manual)
1) Prep
- Commit or stash everything: `git status` should be clean.
- Create a safety branch: `git checkout -b adopt-railsfast-backup`
2) Add RailsFast as a remote
```bash
git remote add railsfast git@github.com:railsfast/railsfast-base.git
git fetch railsfast --prune
```
3) Establish a baseline relationship (don’t change your files)
- This records the template as a merge parent without altering your tree.
```bash
git merge --allow-unrelated-histories -s ours --no-ff -m "Adopt RailsFast upstream at baseline" railsfast/main
```
- Result: histories are now related. Future `git merge railsfast/main` will bring only changes made in the template since this baseline.
4) Add merge guards so protected files always keep your local version
- If you already have `.gitattributes`, add lines like:
```bash
echo '# RailsFast: Preserve local configuration during template merges' >> .gitattributes
echo 'Dockerfile merge=ours' >> .gitattributes
echo '.kamal/secrets merge=ours' >> .gitattributes
echo 'app/views/pwa/manifest.json.erb merge=ours' >> .gitattributes
echo 'config/application.rb merge=ours' >> .gitattributes
echo 'config/database.yml merge=ours' >> .gitattributes
echo 'config/deploy.yml merge=ours' >> .gitattributes
echo 'config/railsfast.yml merge=ours' >> .gitattributes
echo 'config/credentials.yml.enc merge=ours' >> .gitattributes
echo 'config/master.key merge=ours' >> .gitattributes
echo 'config/db.key merge=ours' >> .gitattributes
```
- Configure the merge driver + rerere:
```bash
git config merge.ours.driver true
git config rerere.enabled true
git add .gitattributes
git commit -m "Configure RailsFast merge guards"
```
5) (Optional) Record metadata for RailsFast CLI status
- If your repo already includes `bin/railsfast`, you can record the baseline and remote without changing your app:
```bash
bin/railsfast init --yes --skip-rename --skip-setup
```
- This writes `.railsfast/config.json` and remembers the remote/branch/baseline. If you don’t have the scripts yet, you can skip this step.
### Verifications
- Check relation and baseline:
```bash
git log --oneline --graph --decorate --all | head -n 50
```
- See behind count:
```bash
git rev-list --count HEAD..railsfast/main
```
---
## Deploy multiple Rails apps to the same server
Running multiple apps in the same VPS server is trivial with RailsFast.
Just configure `deploy.yml` to deploy to the same server IP, that's it! Nothing else to do on your side.
All apps in the same server will have separate databases and run in separate containers so there's no risk of cross-pollination whatsoever. Your RailsFast apps are like silos, they just happen to be in the same VPS!
---
## Updating RailsFast
Updating any template is always going to be a bit difficult, because as you start building your app on top of it many files will diverge from the original template, and conflicts will naturally arise. The worst-case scenario is you need to go file-by-file reviewing the template updates and deciding what to incorporate into your app.
HOWEVER, RailsFast is designed so the amount of effort you need to do is minimized, and ideally you can keep pulling RailsFast improvements into your app without much work.
## Make your life easier for updating
As we covered in the [[ui-components/9-customizing|Customizing Components]] section, the best practice if you plan to customize RailsFast components, is that you **don’t edit the RailsFast components in-place**, instead, copy the component you want to change into your own folder and edit your copy.
## Protect your customizations (merge guards)
At some point, you will inevitably edit RailsFast core files. If you *did* edit files that ship with RailsFast and that you can't copy easily into your own folder (for example, layouts or auth views), you can tell Git to always keep *your* version on merges by adding merge guards in `.gitattributes`.
Example:
```gitattributes
# Keep my customized files when merging template updates
app/views/devise/** merge=ours
```
This way, if for example you update all your authentication screens to match your branding and design, when updating RailsFast your files will get priority, overriding the update, and you won't lose your work (you won't get updates on those files, though)
>[!WARNING] My recommendation is you use this only for individual files or small folders. If you guard huge paths like `app/views/**`, you will also silently skip upstream improvements (including fixes) for those files.
## How to update RailsFast
Since we configured the remote in the quickstart, we can just `pull` the latest RailsFast changes:
```bash
git fetch railsfast --prune
```
And then merge them with your project:
```bash
git merge railsfast/main
```
If you get conflicts, resolve them (Git will respect your `.gitattributes` merge guards), and then commit the merge.
## Using AI agents to resolve conflicts
Merge conflicts are inevitable when you've customized template files and the upstream template has improvements to those same files. The good news: **AI coding agents like Claude Code excel at resolving these conflicts intelligently.**
Instead of manually reviewing each conflict, you can instruct an AI agent to:
1. **Run the merge commands** - `git fetch railsfast && git merge railsfast/main`
2. **Understand both sides** - The agent reads your customizations AND the upstream changes
3. **Merge intelligently** - Rather than just picking one side, it can combine the best of both (e.g., keep your dark theme styling while incorporating new auto-discovery features)
4. **Adapt features** - Port upstream features to work with your customizations
Example prompt:
```
Pull the latest changes from railsfast/main and resolve any conflicts.
Keep my custom styling but incorporate new features from upstream.
```
This approach is often faster and smarter than manual conflict resolution, especially when the agent can understand the *intent* behind both your changes and the upstream improvements.
>[!TIP] Git's `rerere` feature ("reuse recorded resolution") can remember how you resolved conflicts, making future merges of the same files easier. Enable it with `git config rerere.enabled true`.
## After updating
Run any new migrations:
```bash
bin/rails db:migrate
```
Restart your dev server (`bin/dev`) and make sure nothing got broken in the update and everything works.
---
## Beyond credits: wallets for games, telecom, and marketplaces
RailsFast's credit system is built on a powerful ledger engine. If your product needs more than simple API credits — like game currencies, mobile data plans, or marketplace balances — you can use the underlying `wallets` gem directly.
## When to go beyond credits
| If you need... | Use |
|----------------|-----|
| API usage billing | `usage_credits` (default) |
| Multiple currencies per user | `wallets` |
| User-to-user transfers | `wallets` |
| Game resources (wood, gold, gems) | `wallets` |
| Marketplace seller balances | `wallets` |
| Mobile data / telecom plans | `wallets` |
## Quick comparison
```
┌─────────────────────────────────────────────────────────────┐
│ usage_credits │
│ "User spends credits on operations" │
│ │
│ user.spend_credits_on(:generate_image) │
│ user.credits → 4,950 │
│ │
│ Built on wallets — wallet-level transfers available: │
│ user.credit_wallet.transfer_to(friend.credit_wallet, 100) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ wallets │
│ "User has balances that move around" │
│ │
│ user.wallet(:gold).transfer_to(friend.wallet(:gold), 100) │
│ user.wallet(:gold).balance → 900 │
│ friend.wallet(:gold).balance → 100 │
└─────────────────────────────────────────────────────────────┘
```
## Example: Mobile data app (eSIM cards)
Users get monthly data and can share unused GBs with friends:
```ruby
# app/models/user.rb
class User < ApplicationRecord
include Wallets::HasWallets
has_wallets default_asset: :data_mb # Store in MB for precision
end
```
```ruby
# Grant monthly data (10 GB = 10,240 MB)
user.wallet(:data_mb).credit(
10_240,
category: :monthly_plan,
expires_at: 1.month.from_now
)
# Network usage depletes balance
user.wallet(:data_mb).debit(512, category: :network_usage)
# User shares 3 GB with a friend
user.wallet(:data_mb).transfer_to(
friend.wallet(:data_mb),
3_072,
category: :data_gift
)
user.wallet(:data_mb).balance # → 6,656 MB remaining
```
## Example: Game economy (FarmVille-style)
Multiple resources, trading between players, seasonal events:
```ruby
class Player < ApplicationRecord
include Wallets::HasWallets
has_wallets default_asset: :gold
end
```
```ruby
# Quest rewards multiple resources
player.wallet(:wood).credit(100, category: :quest_reward)
player.wallet(:stone).credit(50, category: :quest_reward)
player.wallet(:gold).credit(25, category: :quest_reward)
# Crafting consumes resources
player.wallet(:wood).debit(30, category: :crafting)
# Premium currency from in-app purchase
player.wallet(:gems).credit(500, category: :iap_purchase)
# Seasonal currency that expires
player.wallet(:snowflakes).credit(
1_000,
category: :winter_event,
expires_at: Date.new(2024, 1, 7)
)
# Trading between players
player.wallet(:gold).transfer_to(
other_player.wallet(:gold),
100,
category: :trade
)
```
## Example: Marketplace (Etsy/Fiverr-style)
Seller balances, platform fees, payouts:
```ruby
class User < ApplicationRecord
include Wallets::HasWallets
has_wallets default_asset: :usd_cents # Always use cents for money
end
```
```ruby
# Order completed — credit seller minus platform fee
order_total = 5000 # $50.00
platform_fee = (order_total * 0.10).to_i
seller_earnings = order_total - platform_fee
seller.wallet(:usd_cents).credit(
seller_earnings,
category: :sale,
metadata: {
order_id: order.id,
gross: order_total,
fee: platform_fee
}
)
# Seller requests payout
seller.wallet(:usd_cents).debit(
seller.wallet(:usd_cents).balance,
category: :payout,
metadata: { stripe_transfer: "tr_xxx" }
)
# Buyer uses store credit
buyer.wallet(:usd_cents).debit(2000, category: :purchase)
```
## Example: Loyalty programs & Reward points
Whether you're building a Starbucks-style loyalty program, credit card rewards, airline miles, or a Sweatcoin-style earn-from-actions app — it's the same pattern:
```
┌─────────────────────────────────────────────────────────────┐
│ Loyalty program flow │
├─────────────────────────────────────────────────────────────┤
│ EARN │ Purchase, action, referral, promo │
│ HOLD │ Points accumulate, some may expire │
│ TRANSFER │ Gift to family, pool with friends │
│ REDEEM │ Rewards, discounts, gift cards │
└─────────────────────────────────────────────────────────────┘
```
```ruby
class User < ApplicationRecord
include Wallets::HasWallets
has_wallets default_asset: :points
end
# ═══════════════════════════════════════════════════════════
# EARN — from purchases, actions, referrals
# ═══════════════════════════════════════════════════════════
# Points from purchase (1 point per dollar)
user.wallet(:points).credit(order.total_cents / 100, category: :purchase)
# Referral bonus
user.wallet(:points).credit(500, category: :referral)
# Daily check-in streaks
user.wallet(:points).credit(50, category: :daily_checkin)
# Receipt scanning (Ibotta-style)
user.wallet(:points).credit(100, category: :receipt_scan)
# ═══════════════════════════════════════════════════════════
# EXPIRING PROMOS — use-it-or-lose-it campaigns
# ═══════════════════════════════════════════════════════════
# Welcome bonus that expires in 30 days
user.wallet(:points).credit(500, category: :welcome_bonus, expires_at: 30.days.from_now)
# Double points weekend
user.wallet(:points).credit(200, category: :promo, expires_at: Date.current.next_occurring(:monday))
# Birthday reward
user.wallet(:points).credit(1000, category: :birthday, expires_at: 1.month.from_now)
# ═══════════════════════════════════════════════════════════
# TRANSFER — gift to friends, pool with family
# ═══════════════════════════════════════════════════════════
# Gift points to another member
user.wallet(:points).transfer_to(friend.wallet(:points), 500, category: :gift)
# Family pooling
family_members.each do |member|
member.wallet(:points).transfer_to(family_pool.wallet(:points), member.wallet(:points).balance, category: :family_pool)
end
# ═══════════════════════════════════════════════════════════
# REDEEM — rewards, discounts, cash out
# ═══════════════════════════════════════════════════════════
# Redeem for a reward
user.wallet(:points).debit(2500, category: :redemption, metadata: { reward: "free_coffee" })
# Redeem for gift card
user.wallet(:points).debit(10_000, category: :cash_out, metadata: { gift_card_value: 1000 })
```
**This pattern fits:**
- Starbucks Stars, Dunkin' Rewards
- Airline miles (Delta SkyMiles, United MileagePlus)
- Credit card points (Chase Ultimate Rewards, Amex MR)
- Hotel points (Marriott Bonvoy, Hilton Honors)
- Retail loyalty (Sephora Beauty Insider, REI Co-op)
- Cashback apps (Rakuten, Ibotta, Fetch)
- Fitness rewards (Sweatcoin, Stepn)
## Transfer expiration policies
When transferring between users, what happens to expiration dates?
By default, transfers **preserve expiration buckets**. If Alice transfers credits from multiple buckets with different expirations, Bob receives multiple inbound credit transactions so those expirations remain intact.
| Policy | Behavior | Best for |
|--------|----------|----------|
| `:preserve` (default) | Keeps source bucket expirations | Loyalty points, seasonal currencies |
| `:none` | Receiver gets evergreen credits | Money, store credit |
| `:fixed` | Explicit `expires_at` on receive | Promotional transfers |
```ruby
# Default — preserve expirations from source buckets
alice.wallet(:points).transfer_to(bob.wallet(:points), 100)
# Evergreen on receive (no expiration)
alice.wallet(:usd_cents).transfer_to(
bob.wallet(:usd_cents),
500,
expiration_policy: :none
)
# Fixed expiration on receive
alice.wallet(:promo_credits).transfer_to(
bob.wallet(:promo_credits),
200,
expiration_policy: :fixed,
expires_at: 30.days.from_now
)
```
## Using both together
You can use `usage_credits` for your API billing AND `wallets` for other balances in the same app:
```ruby
class User < ApplicationRecord
has_credits # API credits via usage_credits
include Wallets::HasWallets # Additional wallets
has_wallets default_asset: :reward_points
end
```
```ruby
# API operations use credits
user.spend_credits_on(:generate_report)
# Rewards use wallets
user.wallet(:reward_points).credit(100, category: :signup_bonus)
```
They're completely isolated — different tables, different configs, no conflicts.
## Going deeper
- [`wallets` gem documentation](https://github.com/rameerez/wallets) — full API reference
- [`usage_credits` gem documentation](https://github.com/rameerez/usage_credits) — for API/SaaS billing
The key insight: **wallets is for holding and moving value**, **usage_credits is for selling and consuming value**. Pick the right tool for your use case.
---
Back to [[features/14-usage_credits|Usage credits]] for the standard API billing setup.
---