| description | General Rails rules |
|---|---|
| globs | app/**/*.rb |
| alwaysApply | true |
This projects runs in docker container, managed with docker-compose.
You must run rspec in the api container, use lago exec api bundle exec rspec <args>.
You must use the rails cli in the container too, for example: lago exec api bin/rails db:migrate.
- Never use
OpenStruct - avoid
if/unlessmodifier right before the last line. USEAVOIDif something this else that endreturn than unless something this
All commit messages must follow the Conventional Commits specification:
<type>[optional scope]: <description>
## Context
...
## Description
...
Where:
<type>is one of: feat, fix, docs, style, refactor, test, chore, perf, ci, build, revert, misc[optional scope]is optional and describes the area of change (e.g., auth, billing, api)<description>is a short description of the change in imperative mood
When generating or amending commit messages:
- The first line must be 50 characters or less
- Use the imperative mood ("Add feature" not "Added feature")
- The body should
- Explain the context and rationale for the change
- Explain the "why" and "what" at a conceptual level, not the "how" at a code level
- Be simple and direct using complete sentences without being verbose while keeping as much information as possible
- Check the whole diff at once using
PAGER=cat git diff ...to see all changes together - Generate the commit message based on the actual changes, not assumptions
- Do not check previous commits or commit history
- Only describe what was actually added or changed, not what already existed in the files
When creating new commits:
- Only analyze the current staged changes (
PAGER=cat git diff --staged)
When amending commits:
- Always check the actual commit content first using
PAGER=cat git show HEADto see all changes andPAGER=cat git diff --stagedto see staged changes - Use
git commit --amend -m "message"to update the commit message
- Creating, updating an deleting model must be done using a dedicated service, unless instructed otherwise.
For instance, to create an Alert model, you should create a
CreateAlertServiceclass. - Before deleting a model, inspect it to determine if it's soft deletable (it includes
Discard::Model). If soft deletable, usemodel.discard. Never hard delete a soft deletable model.
When creating a service class:
- the class always extend BaseService using
< - the class name should always end with
Service - the class should always be placed in
app/services/**/*.rb - Service class takes named arguments via the constructor and arguments are stored in instance variables.
- Each instance variable should have a private attr_reader declared
- Service class have one and only one public method named
calland it never accepts arguments - Service
callmethod should always returnresult - Service class must define a custom Result class following these rules:
- By default,
Result = BaseResult - If the service must return values, define them using
BaseResult[]. Example of result returning a customer and a subcription:Result = BaseResult[:customer, :subscription]
- By default,
To call the class service class asynchronously, create job:
- jobs should have the exact same fully qualified class name except it ends with
Jobinstead ofService. - the perform method of the job typically calls the matching service and forwards all it's arguements
- the service is called using the class method
call! - avoid using named parameters for jobs
Example of job calling a service:
# frozen_string_literal: true
module SomeModuleName
class MyGeneratedJob < ApplicationJob
queue_as "default"
def perform(organization, subscription)
SomeModuleName::MyGeneratedService.call!(organization:, subscription:)
end
end
end- Under
V1namespace the resource retrieved should always be scoped to the current_organization. Typically, to retrieve Alerts, usecurrent_organization.alerts.where(...) - In controller
createmethod, return regular 200 status, avoidstatus: :created - When testing controller, access the response via
jsonmethod, which parsed json and symbolized keys.
- New models must directly belong to an organization. Store the
organization_idin the table, don't usethrough:
Soft deletion
- not all models are soft deletable
- soft deletable models must
include Discard::Model - the soft deletion column is called
deleted_at - soft deletable models must use
default_scope -> { kept } - soft deletable models should be deletable
- You cannot rely on
dependent: :destroyif the model is soft deleted, you must calldiscard_all!on relationship manually
- Define enum constants as arrays before using them in enum declarations
- For PostgreSQL enums, define constants as hashes with string values, not arrays. Use the format:
ENUM_NAME = { value1: "value1", value2: "value2" }.freeze. - New model enums should always use
validate: true
Example:
FEE_TYPES = %i[charge add_on subscription credit commitment].freeze
enum :fee_type, FEE_TYPES
ON_TERMINATION_CREDIT_NOTES = { credit: "credit", omit: "omit" }.freeze
enum :on_termination_credit_note, ON_TERMINATION_CREDIT_NOTESTo create a webhook:
- A webhook name is typically
resource.action, for example:customer.updatedoralert.triggered. Use other webhooks as example to follow. - Create a service in
app/services/webhooks/, typically namedWebhooks::ResourceActionServicelikeWebhooks::CustomerUpdatedService - A service must define at least the following methods:
current_organization- how to get the organization from the modelobject_serializerwhich typically calls a serializer classwebhook_typealways the name likeresource.actionobject_typewhich is the object serialized. Reuse this method in the serializerroot_nameparam
- Add the mapping
name=> Service class to theSendWebhookJob::WEBHOOK_SERVICEShash - Write a test for the webhook class
- Make sure to specify the latest available
ActiveRecord::Migrationversion. For example, if the latest version is8.0, useActiveRecord::Migration[8.0]. - Prefer
add_columnoverchange_tablewhen adding single columns - Use
safety_assuredwrapper when required for complex operations - Never use hardcoded or fake timestamps in migration filenames. Migration timestamps should be generated using
date +"%Y%m%d%H%M%S"command to ensure proper chronological ordering. - For enums, use
create_enumto define the PostgreSQL enum type before adding the column- Enum type names should be descriptive and include the table/model context (e.g.,
subscription_on_termination_credit_note)
- Enum type names should be descriptive and include the table/model context (e.g.,
- When adding a constraint with
validate: false(NOT VALID), add a second migration right after it that validates the constraint (validate_foreign_keyorvalidate_check_constraint), within the same PR. If validation is not possible yet (e.g. existing rows must be backfilled or fixed first), register the constraint indb/not_valid_constraints.ymlwith avalidate_bydeadline, and remove the entry once the constraint is validated.spec/db/not_valid_constraints_spec.rbenforces this. - When validating a foreign key on a table that has several foreign keys to the same target table, pass the
column:option tovalidate_foreign_key, otherwise the wrong constraint may be validated silently.
- Clickhouse migrations live in
db/clickhouse_migrate/(self-hosted). The DDL for ClickHouse Cloud is kept separately indb/clickhouse_migrate/cloud/*.sql; those files are executed manually when creating a new cluster, so they are edited in place to reflect the current schema. - Clickhouse DDL is not transactional. Keep one DDL concern per migration (e.g. one index): if a migration runs several statements and a later one fails, the earlier ones are already applied while the migration is marked as failed.
- Define explicit
upanddownmethods (notchange), and makedownrevert the DDL (e.g.DROP INDEX IF EXISTS). UseIF NOT EXISTS/IF EXISTSguards so retries are idempotent.
- New optional parameters must not break existing functionality
- When a change introduces a new environment variable, document it in this file (name, purpose, example value)
LAGO_ENABLE_YJIT— when true, enables YJIT (config.yjit). Disabled by default. Example:LAGO_ENABLE_YJIT=trueSIDEKIQ_WALLETS— when true, wallet jobs (e.g.Customers::RefreshWalletJob) are routed to thewalletsqueue, processed by the dedicated wallet worker (scripts/start.wallets.worker.sh). Example:SIDEKIQ_WALLETS=trueSIDEKIQ_AI_AGENT— when true, AI conversation jobs (AiConversations::StreamJob) are routed to theai_agentqueue. Example:SIDEKIQ_AI_AGENT=true- The
streamingqueue (config/sidekiq/sidekiq_streaming.yml,scripts/start.streaming.worker.sh) has noSIDEKIQ_*flag on purpose.DeliverEventJobis pinned to it unconditionally, because its worker needs an AWS identity the general workers do not have, and a wallet-refresh burst arrives as one job per customer and must not compete with billing work. Nothing is enqueued unless the organization has astreaming_destinationsrow, so a deployment that runs no streaming worker never fills the queue. LAGO_REALTIME_USAGE_ENABLED— when true, allows current usage to be served from the pre-aggregated ClickHouse usage buckets. The value is cast as a boolean, sofalseor0disables serving. Deployment-wide kill switch: serving also requires a premium license,LAGO_CLICKHOUSE_ENABLED, the organization reading the ClickHouse events store, and the per-organizationrealtime_usagefeature flag. Example:LAGO_REALTIME_USAGE_ENABLED=trueLAGO_FINANCE_ASSISTANT_URL— base URL of the finance assistant service that answersaskFinanceAssistant. When blank the feature is unavailable and the mutation returns a forbidden failure. Example:LAGO_FINANCE_ASSISTANT_URL=http://lago-data-agent:8000LAGO_FINANCE_ASSISTANT_OPEN_TIMEOUT— connection timeout, in seconds, for the finance assistant call. Defaults to 5. Example:LAGO_FINANCE_ASSISTANT_OPEN_TIMEOUT=5LAGO_FINANCE_ASSISTANT_READ_TIMEOUT— response timeout, in seconds, for the finance assistant call. Defaults to 60. Must stay above the assistant's own run deadline (ASK_DEADLINE_SECS, 55s today) so that a slow answer is received instead of being cut off. Example:LAGO_FINANCE_ASSISTANT_READ_TIMEOUT=60LAGO_SMTP_AUTHENTICATION— SMTP authentication method, one oflogin(default),plain,cram_md5orxoauth2.noneordisabledturn authentication off and dropLAGO_SMTP_USERNAME/LAGO_SMTP_PASSWORDfrom the mailer settings, which is required because net-smtp authenticates with PLAIN as soon as a username is present; use it only for a trusted self-hosted relay. A blank value keeps the default, so a variable left empty by a compose file or a Helm chart cannot silently change authentication. Any other value makes every delivery fail —rails lago:diagnosticsflags it. Example:LAGO_SMTP_AUTHENTICATION=noneLAGO_SMTP_ENABLE_STARTTLS_AUTO— whether SMTP automatically uses STARTTLS. Defaults to true; set it to false only for a trusted self-hosted relay without TLS. Example:LAGO_SMTP_ENABLE_STARTTLS_AUTO=false- Sensitive values (keys, secrets, passwords, tokens, credentials embedded in URLs) must always be masked in examples, e.g.
LAGO_SMTP_PASSWORD=***orDATABASE_URL=postgresql://***@db:5432/lago
- Use descriptive error messages that explain why validation failed
- Always validate enum values in a service validation class to prevent invalid API input
- When using ransack with search_params, make sure the attributes are defined in the model class method
self.ransackable_attributes(_auth_object = nil)
- Do not test
#initializemethod. - In controller specs, use
get_with_tokenand similar method, don't try to mock the token manually - to test a "resource not found error" from an
Api::V1controller, use the custom matchbe_not_found_errorlike this:expect(response).to be_not_found_error("alert") - Prefer
expect(...).to have_received()instead ofexpect(...).to receive() - Configure mocks and stubs with
allowinbeforeblocks, never directly in anitblock. When setup applies to only one example, wrap it in a dedicated context with its ownbeforeblock. - never use
aggregate_failurein new test. Do not edit existing tests to remove it. - After making changes to the tests, always run the tests to ensure they pass.
- When doing array comparison in tests, use
eqormatch_arrayinstead of multipleinclude/not_to includeassertions when the expected array is small enough to be readable - Use single-line
letstatements when they fit on one line without breaking Rubocop rules - Start RSpec
contextdescriptions withwhen,with, orwithoutto satisfyRSpec/ContextWording(e.g.context "when the segment starts after the rate change"). - Define the object under test with a named
subjectinstead of constructingdescribed_classinsideitblocks. Keep examples focused on exercising behavior and asserting results. - Define shared setup objects with
letinstead of repeating factory calls across examples. Extract inputs that vary between scenarios (e.g.timezone,started_at, andended_at) intoletdeclarations, and override only those inputs in nestedcontextblocks while reusing the same subject. - Declare test fixtures with
letorbefore; do not callbuildorcreateinside anitblock. - Define scenario state through factory arguments in nested contexts instead of using
assign_attributes,update!, or direct attribute assignment inside examples. - Mutate records inside examples only when the mutation itself is the behavior under test.
- Run as minimum number of tests as possible. Narrow down run tests for specific describe or file.
- When testing models, test all enums and group them all in a
describe "enums"block with a singleitblock (not multipleitblocks)- When testing PostgreSQL enums, use the
.backed_by_column_of_type(:enum)matcher:expect(subject).to define_enum_for(:on_termination_credit_note) .backed_by_column_of_type(:enum) .validating .with_values(credit: "credit", omit: "omit")
- When testing PostgreSQL enums, use the
- When testing models, test ALL associations (belongs_to, has_one, has_many, etc.) and group them all in a
describe "associations"block with a singleitblock (not multipleitblocks)- Include ALL association parameters and options (class_name, foreign_key, through, dependent, autosave, optional, etc.)
- Clickhouse associations should have their own
describe "Clickhouse associations"block withclickhouse: truemetadata after the associations block
- When testing models, test all scopes and group them all in a
describe "Scopes"block with individualdescribe ".scope_name"blocks for each scope - When testing models, test all validations and group them all in a
describe "validations"block with a singleitblock- For complex custom validations, use a nested
describe "attribute_name validation"block instead of using the method name
- For complex custom validations, use a nested
- Test sections should appear in this order: enums, associations, Clickhouse associations, scopes, validations
- Prefer
buildorbuild_stubbedovercreatewhen the test does not require database persistence. Preferbuild_stubbedwhen identity assertions need IDs. - Build the target factory directly and rely on its associations instead of manually creating the full associated object graph.
- Some factories have been renamed for clarity.
- To create Entitlement::Feature model, use
:feature - To create Entitlement::Privilege model, use
:privilege - To create Entitlement::Entitlement model, use
:entitlement
- To create Entitlement::Feature model, use