Skip to content

Latest commit

 

History

History
255 lines (196 loc) · 16 KB

File metadata and controls

255 lines (196 loc) · 16 KB
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.

General style

  • Never use OpenStruct
  • avoid if/unless modifier right before the last line. USE
    if something
        this
    else
        that
    end
    
    AVOID
    return than unless something
    this
    

Commit Messages

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 HEAD to see all changes and PAGER=cat git diff --staged to see staged changes
  • Use git commit --amend -m "message" to update the commit message

Services

  • 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 CreateAlertService class.
  • Before deleting a model, inspect it to determine if it's soft deletable (it includes Discard::Model). If soft deletable, use model.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 call and it never accepts arguments
  • Service call method should always return result
  • 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]

Jobs

To call the class service class asynchronously, create job:

  • jobs should have the exact same fully qualified class name except it ends with Job instead of Service.
  • 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

Controllers

  • Under V1 namespace the resource retrieved should always be scoped to the current_organization. Typically, to retrieve Alerts, use current_organization.alerts.where(...)
  • In controller create method, return regular 200 status, avoid status: :created
  • When testing controller, access the response via json method, which parsed json and symbolized keys.

Models

  • New models must directly belong to an organization. Store the organization_id in the table, don't use through:

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: :destroy if the model is soft deleted, you must call discard_all! on relationship manually

Enums

  • 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_NOTES

Webhooks

To create a webhook:

  • A webhook name is typically resource.action, for example: customer.updated or alert.triggered. Use other webhooks as example to follow.
  • Create a service in app/services/webhooks/, typically named Webhooks::ResourceActionService like Webhooks::CustomerUpdatedService
  • A service must define at least the following methods:
    • current_organization - how to get the organization from the model
    • object_serializer which typically calls a serializer class
    • webhook_type always the name like resource.action
    • object_type which is the object serialized. Reuse this method in the serializer root_name param
  • Add the mapping name => Service class to the SendWebhookJob::WEBHOOK_SERVICES hash
  • Write a test for the webhook class

Migrations

  • Make sure to specify the latest available ActiveRecord::Migration version. For example, if the latest version is 8.0, use ActiveRecord::Migration[8.0].
  • Prefer add_column over change_table when adding single columns
  • Use safety_assured wrapper 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_enum to 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)
  • When adding a constraint with validate: false (NOT VALID), add a second migration right after it that validates the constraint (validate_foreign_key or validate_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 in db/not_valid_constraints.yml with a validate_by deadline, and remove the entry once the constraint is validated. spec/db/not_valid_constraints_spec.rb enforces this.
  • When validating a foreign key on a table that has several foreign keys to the same target table, pass the column: option to validate_foreign_key, otherwise the wrong constraint may be validated silently.

Clickhouse migrations

  • Clickhouse migrations live in db/clickhouse_migrate/ (self-hosted). The DDL for ClickHouse Cloud is kept separately in db/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 up and down methods (not change), and make down revert the DDL (e.g. DROP INDEX IF EXISTS). Use IF NOT EXISTS / IF EXISTS guards so retries are idempotent.

Backward Compatibility

  • New optional parameters must not break existing functionality

Environment variables

  • 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=true
  • SIDEKIQ_WALLETS — when true, wallet jobs (e.g. Customers::RefreshWalletJob) are routed to the wallets queue, processed by the dedicated wallet worker (scripts/start.wallets.worker.sh). Example: SIDEKIQ_WALLETS=true
  • SIDEKIQ_AI_AGENT — when true, AI conversation jobs (AiConversations::StreamJob) are routed to the ai_agent queue. Example: SIDEKIQ_AI_AGENT=true
  • The streaming queue (config/sidekiq/sidekiq_streaming.yml, scripts/start.streaming.worker.sh) has no SIDEKIQ_* flag on purpose. DeliverEventJob is 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 a streaming_destinations row, 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, so false or 0 disables serving. Deployment-wide kill switch: serving also requires a premium license, LAGO_CLICKHOUSE_ENABLED, the organization reading the ClickHouse events store, and the per-organization realtime_usage feature flag. Example: LAGO_REALTIME_USAGE_ENABLED=true
  • LAGO_FINANCE_ASSISTANT_URL — base URL of the finance assistant service that answers askFinanceAssistant. When blank the feature is unavailable and the mutation returns a forbidden failure. Example: LAGO_FINANCE_ASSISTANT_URL=http://lago-data-agent:8000
  • LAGO_FINANCE_ASSISTANT_OPEN_TIMEOUT — connection timeout, in seconds, for the finance assistant call. Defaults to 5. Example: LAGO_FINANCE_ASSISTANT_OPEN_TIMEOUT=5
  • LAGO_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=60
  • LAGO_SMTP_AUTHENTICATION — SMTP authentication method, one of login (default), plain, cram_md5 or xoauth2. none or disabled turn authentication off and drop LAGO_SMTP_USERNAME/LAGO_SMTP_PASSWORD from 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:diagnostics flags it. Example: LAGO_SMTP_AUTHENTICATION=none
  • LAGO_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=*** or DATABASE_URL=postgresql://***@db:5432/lago

Service

Validation

  • Use descriptive error messages that explain why validation failed
  • Always validate enum values in a service validation class to prevent invalid API input

Query object

  • When using ransack with search_params, make sure the attributes are defined in the model class method self.ransackable_attributes(_auth_object = nil)

Testing

  • Do not test #initialize method.
  • In controller specs, use get_with_token and similar method, don't try to mock the token manually
  • to test a "resource not found error" from an Api::V1 controller, use the custom match be_not_found_error like this: expect(response).to be_not_found_error("alert")
  • Prefer expect(...).to have_received() instead of expect(...).to receive()
  • Configure mocks and stubs with allow in before blocks, never directly in an it block. When setup applies to only one example, wrap it in a dedicated context with its own before block.
  • never use aggregate_failure in 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 eq or match_array instead of multiple include/not_to include assertions when the expected array is small enough to be readable
  • Use single-line let statements when they fit on one line without breaking Rubocop rules
  • Start RSpec context descriptions with when, with, or without to satisfy RSpec/ContextWording (e.g. context "when the segment starts after the rate change").
  • Define the object under test with a named subject instead of constructing described_class inside it blocks. Keep examples focused on exercising behavior and asserting results.
  • Define shared setup objects with let instead of repeating factory calls across examples. Extract inputs that vary between scenarios (e.g. timezone, started_at, and ended_at) into let declarations, and override only those inputs in nested context blocks while reusing the same subject.
  • Declare test fixtures with let or before; do not call build or create inside an it block.
  • 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.

Models

  • When testing models, test all enums and group them all in a describe "enums" block with a single it block (not multiple it blocks)
    • 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 models, test ALL associations (belongs_to, has_one, has_many, etc.) and group them all in a describe "associations" block with a single it block (not multiple it blocks)
    • 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 with clickhouse: true metadata after the associations block
  • When testing models, test all scopes and group them all in a describe "Scopes" block with individual describe ".scope_name" blocks for each scope
  • When testing models, test all validations and group them all in a describe "validations" block with a single it block
    • For complex custom validations, use a nested describe "attribute_name validation" block instead of using the method name
  • Test sections should appear in this order: enums, associations, Clickhouse associations, scopes, validations

Factories

  • Prefer build or build_stubbed over create when the test does not require database persistence. Prefer build_stubbed when 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